silver-platter-0.5.44/.bzrignore0000644000000000000000000000004314721061524013510 0ustar00build dist silver_platter.egg-info silver-platter-0.5.44/.codespellrc0000644000000000000000000000005714721061524014013 0ustar00[codespell] ignore-words-list = crate,framwork silver-platter-0.5.44/AUTHORS0000644000000000000000000000011214721061524012553 0ustar00Jelmer Vernooij Filippo Giunchedi silver-platter-0.5.44/CODE_OF_CONDUCT.md0000644000000000000000000000642314721061524014315 0ustar00# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jelmer@jelmer.uk. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq silver-platter-0.5.44/Cargo.lock0000644000000000000000000024732614721061524013434 0ustar00# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "addr2line" version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] [[package]] name = "adler2" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "android-tzdata" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anstream" version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", "windows-sys 0.59.0", ] [[package]] name = "arc-swap" version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "backtrace" version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", "windows-targets 0.52.6", ] [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bindgen" version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", "itertools", "log", "prettyplease", "proc-macro2", "quote", "regex", "rustc-hash", "shlex", "syn 2.0.89", ] [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "breezyshim" version = "0.1.227" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe63ba40f1cf5aed5af3577f45cc5692e69f9bbf7b12950a87dd2469b9c3183" dependencies = [ "chrono", "ctor", "debian-changelog", "debian-control", "debversion", "difflib", "dirty-tracker", "lazy-regex", "lazy_static", "log", "patchkit", "percent-encoding", "pyo3", "pyo3-filelike", "serde", "tempfile", "url", ] [[package]] name = "bstr" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" dependencies = [ "memchr", "serde", ] [[package]] name = "build-rs" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b00b8763668c99f8d9101b8a0dd82106f58265464531a79b2cef0d9a30c17dd2" [[package]] name = "bumpalo" version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" [[package]] name = "cc" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" dependencies = [ "shlex", ] [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ "nom", ] [[package]] name = "cfg-expr" version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", "target-lexicon", ] [[package]] name = "cfg-expr" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c360837f8f19e2e4468275138f1c0dec1647d1e17bb7c0215fe3cd7530e93c25" dependencies = [ "smallvec", "target-lexicon", ] [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", "windows-targets 0.52.6", ] [[package]] name = "chrono-tz" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" dependencies = [ "chrono", "chrono-tz-build", "phf", ] [[package]] name = "chrono-tz-build" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", "phf", "phf_codegen", ] [[package]] name = "clang-sys" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", "libloading", ] [[package]] name = "clap" version = "4.5.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.5.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_derive" version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "clap_lex" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" [[package]] name = "colorchoice" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "configparser" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57e3272f0190c3f1584272d613719ba5fc7df7f4942fe542e63d949cf3a649b" [[package]] name = "conv" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ff10625fd0ac447827aa30ea8b861fead473bb60aeb73af6c1c58caf0d1299" dependencies = [ "custom_derive", ] [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "countme" version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpufeatures" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" dependencies = [ "libc", ] [[package]] name = "crc32fast" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" dependencies = [ "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", ] [[package]] name = "cstr-argument" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bd9c8e659a473bce955ae5c35b116af38af11a7acb0b480e01f3ed348aeb40" dependencies = [ "cfg-if", "memchr", ] [[package]] name = "csv" version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" dependencies = [ "csv-core", "itoa", "ryu", "serde", ] [[package]] name = "csv-core" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" dependencies = [ "memchr", ] [[package]] name = "ctor" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", "syn 2.0.89", ] [[package]] name = "custom_derive" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef8ae57c4978a2acd8b869ce6b9ca1dfe817bff704c220209fdef2c0b75a01b9" [[package]] name = "deb822-derive" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b6e5cafe61e77421a090e2a33b8a2e4e2ff1b106fd906ebade111307064d981" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "deb822-lossless" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812bb5c8052a89edc6d45d1bc3b3400e8186dd166e9b0a9520bfa5a2bd8477ee" dependencies = [ "deb822-derive", "pyo3", "regex", "rowan 0.16.0", "serde", ] [[package]] name = "debian-analyzer" version = "0.158.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a82b2f48997e6d74e716beb0bd364ed3678ba28f615c25f7841a32d115d71da" dependencies = [ "breezyshim", "chrono", "configparser", "deb822-lossless", "debian-changelog", "debian-control", "debian-copyright", "debversion", "dep3", "difflib", "distro-info", "filetime", "hex", "lazy-regex", "lazy_static", "log", "makefile-lossless", "maplit", "merge3", "patchkit", "pyo3", "quote", "reqwest", "semver", "serde", "serde_json", "sha1", "tempfile", "toml_edit", "url", ] [[package]] name = "debian-changelog" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98258e2066472d8d04bfe8a3fab2e7be77fe87b913dab2308a59f13061145814" dependencies = [ "chrono", "debversion", "lazy-regex", "log", "rowan 0.15.16", "textwrap", "whoami", ] [[package]] name = "debian-control" version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8a22cedff98f1dde7406971869258ec8837728042cdcd9daf5795b6bc5becb5" dependencies = [ "chrono", "deb822-lossless", "debversion", "pyo3", "regex", "rowan 0.16.0", "url", ] [[package]] name = "debian-copyright" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e893a383c33c4e2689fd3c3121d6e82193211f65e7c483463c816f6b7c29857e" dependencies = [ "deb822-lossless", "debversion", "regex", ] [[package]] name = "debversion" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b892997e53d52f9ac5c30bdac09cbea6bb1eeb3f93a204b8548774081a44b496" dependencies = [ "chrono", "lazy-regex", "pyo3", "serde", ] [[package]] name = "dep3" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22009dcff95c439fb3317f9726556e9e3e5ece2ec7c249b667a66f8f35e05018" dependencies = [ "chrono", "deb822-lossless", "url", ] [[package]] name = "deunicode" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", ] [[package]] name = "dirty-tracker" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57f673af5cabab0d10b822fae4b348c2f5fdc56d90474e26f5dcde0f94fce488" dependencies = [ "notify", "tempfile", ] [[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "distro-info" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef12237f2ced990e453ec0b69230752e73be0a357817448c50a62f8bbbe0ca71" dependencies = [ "chrono", "csv", "failure", ] [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "env_filter" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" dependencies = [ "log", "regex", ] [[package]] name = "env_logger" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" dependencies = [ "anstream", "anstyle", "env_filter", "humantime", "log", ] [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "failure" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" dependencies = [ "backtrace", "failure_derive", ] [[package]] name = "failure_derive" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", "synstructure 0.12.6", ] [[package]] name = "fastrand" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" [[package]] name = "filetime" version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" dependencies = [ "cfg-if", "libc", "libredox", "windows-sys 0.59.0", ] [[package]] name = "flate2" version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" dependencies = [ "crc32fast", "miniz_oxide", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ "foreign-types-shared", ] [[package]] name = "foreign-types-shared" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] [[package]] name = "fsevent-sys" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" dependencies = [ "libc", ] [[package]] name = "futures-channel" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-sink" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "getrandom" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", "wasi", ] [[package]] name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "globset" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" dependencies = [ "aho-corasick", "bstr", "log", "regex-automata", "regex-syntax", ] [[package]] name = "globwalk" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ "bitflags 2.6.0", "ignore", "walkdir", ] [[package]] name = "gpg-error" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "545aae14d0e95734d639c8076304e6e86de765c19c76bead3648583d9caed919" dependencies = [ "libgpg-error-sys", ] [[package]] name = "gpgme" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57539732fbe58eacdb984734b72b470ed0bca3ab7a49813271878567025ac44f" dependencies = [ "bitflags 1.3.2", "cfg-if", "conv", "cstr-argument", "gpg-error", "gpgme-sys", "libc", "memoffset 0.7.1", "once_cell", "smallvec", "static_assertions", ] [[package]] name = "gpgme-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "509223d659c06e4a26229437d6ac917723f02d31917c86c6ecd50e8369741cf7" dependencies = [ "build-rs", "libc", "libgpg-error-sys", "system-deps 6.2.2", "winreg 0.10.1", ] [[package]] name = "h2" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", "http", "indexmap", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", "itoa", ] [[package]] name = "http-body" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", ] [[package]] name = "http-body-util" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", "http", "http-body", "pin-project-lite", ] [[package]] name = "httparse" version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "humansize" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" dependencies = [ "libm", ] [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" dependencies = [ "bytes", "futures-channel", "futures-util", "h2", "http", "http-body", "httparse", "itoa", "pin-project-lite", "smallvec", "tokio", "want", ] [[package]] name = "hyper-rustls" version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", "http", "hyper", "hyper-util", "rustls", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", ] [[package]] name = "hyper-tls" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", "hyper", "hyper-util", "native-tls", "tokio", "tokio-native-tls", "tower-service", ] [[package]] name = "hyper-util" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" dependencies = [ "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", "pin-project-lite", "socket2", "tokio", "tower-service", "tracing", ] [[package]] name = "iana-time-zone" version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locid" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_locid_transform" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" dependencies = [ "displaydoc", "icu_locid", "icu_locid_transform_data", "icu_provider", "tinystr", "zerovec", ] [[package]] name = "icu_locid_transform_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" [[package]] name = "icu_normalizer" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "utf16_iter", "utf8_iter", "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" [[package]] name = "icu_properties" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ "displaydoc", "icu_collections", "icu_locid_transform", "icu_properties_data", "icu_provider", "tinystr", "zerovec", ] [[package]] name = "icu_properties_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" [[package]] name = "icu_provider" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ "displaydoc", "icu_locid", "icu_provider_macros", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_provider_macros" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "idna" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "ignore" version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" dependencies = [ "crossbeam-deque", "globset", "log", "memchr", "regex-automata", "same-file", "walkdir", "winapi-util", ] [[package]] name = "indexmap" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", "hashbrown 0.15.1", ] [[package]] name = "indoc" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" [[package]] name = "inotify" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" dependencies = [ "bitflags 1.3.2", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" dependencies = [ "libc", ] [[package]] name = "ipnet" version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" [[package]] name = "js-sys" version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" dependencies = [ "wasm-bindgen", ] [[package]] name = "kqueue" version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" dependencies = [ "kqueue-sys", "libc", ] [[package]] name = "kqueue-sys" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" dependencies = [ "bitflags 1.3.2", "libc", ] [[package]] name = "lazy-regex" version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d8e41c97e6bc7ecb552016274b99fbb5d035e8de288c582d9b933af6677bfda" dependencies = [ "lazy-regex-proc_macros", "once_cell", "regex", ] [[package]] name = "lazy-regex-proc_macros" version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76e1d8b05d672c53cb9c7b920bbba8783845ae4f0b076e02a3db1d02c81b4163" dependencies = [ "proc-macro2", "quote", "regex", "syn 2.0.89", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" version = "0.2.164" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" [[package]] name = "libgpg-error-sys" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "500a4cbc0816ed820a5bcf73a19e74dd6df4bedeabc0f64471c61186938b6c82" dependencies = [ "build-rs", "system-deps 6.2.2", "winreg 0.52.0", ] [[package]] name = "libloading" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", "windows-targets 0.52.6", ] [[package]] name = "libm" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" [[package]] name = "libredox" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.6.0", "libc", "redox_syscall", ] [[package]] name = "linux-raw-sys" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "litemap" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" [[package]] name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "makefile-lossless" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89d2abc0c3de4f7838c3fd8ca411030bc6d5504ea4ccda3791e496c49bd825d0" dependencies = [ "log", "rowan 0.16.0", ] [[package]] name = "maplit" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memoffset" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" dependencies = [ "autocfg", ] [[package]] name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "merge3" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4b277bc3c7e2bc163abc6c0069f53715b52dc34442c0e807cc8758c7113524f" dependencies = [ "clap", "difflib", ] [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ "adler2", ] [[package]] name = "mio" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", "wasi", "windows-sys 0.48.0", ] [[package]] name = "mio" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi", "libc", "wasi", "windows-sys 0.52.0", ] [[package]] name = "native-tls" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" dependencies = [ "libc", "log", "openssl", "openssl-probe", "openssl-sys", "schannel", "security-framework", "security-framework-sys", "tempfile", ] [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", ] [[package]] name = "notify" version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ "bitflags 2.6.0", "crossbeam-channel", "filetime", "fsevent-sys", "inotify", "kqueue", "libc", "log", "mio 0.8.11", "walkdir", "windows-sys 0.48.0", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "object" version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] [[package]] name = "once_cell" version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "openssl" version = "0.10.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" dependencies = [ "bitflags 2.6.0", "cfg-if", "foreign-types", "libc", "once_cell", "openssl-macros", "openssl-sys", ] [[package]] name = "openssl-macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" version = "0.9.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "parse-zoneinfo" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" dependencies = [ "regex", ] [[package]] name = "patchkit" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31e00ed52656b51f535293e40caf6579b4cf09f25e660fc6e6936ab70e78972f" dependencies = [ "chrono", "lazy-regex", "lazy_static", "once_cell", "regex", ] [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442" dependencies = [ "memchr", "thiserror", "ucd-trie", ] [[package]] name = "pest_derive" version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d214365f632b123a47fd913301e14c946c61d1c183ee245fa76eb752e59a02dd" dependencies = [ "pest", "pest_generator", ] [[package]] name = "pest_generator" version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb55586734301717aea2ac313f50b2eb8f60d2fc3dc01d190eefa2e625f60c4e" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "pest_meta" version = "2.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75da2a70cf4d9cb76833c990ac9cd3923c9a8905a8929789ce347c84564d03d" dependencies = [ "once_cell", "pest", "sha2", ] [[package]] name = "phf" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ "phf_shared", ] [[package]] name = "phf_codegen" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" dependencies = [ "phf_generator", "phf_shared", ] [[package]] name = "phf_generator" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" dependencies = [ "phf_shared", "rand", ] [[package]] name = "phf_shared" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" dependencies = [ "siphasher", ] [[package]] name = "pin-project-lite" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "portable-atomic" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" [[package]] name = "ppv-lite86" version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ "zerocopy", ] [[package]] name = "prettyplease" version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ "proc-macro2", "syn 2.0.89", ] [[package]] name = "proc-macro2" version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" dependencies = [ "cfg-if", "chrono", "indoc", "libc", "memoffset 0.9.1", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", "serde", "unindent", ] [[package]] name = "pyo3-build-config" version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" dependencies = [ "once_cell", "target-lexicon", ] [[package]] name = "pyo3-ffi" version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" dependencies = [ "libc", "pyo3-build-config", ] [[package]] name = "pyo3-filelike" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4d4ba4774757be317f112fb7b09f9d2c157a77f07d229a08144f275223f06e8" dependencies = [ "pyo3", ] [[package]] name = "pyo3-log" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ac84e6eec1159bc2a575c9ae6723baa6ee9d45873e9bebad1e3ad7e8d28a443" dependencies = [ "arc-swap", "log", "pyo3", ] [[package]] name = "pyo3-macros" version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", "syn 2.0.89", ] [[package]] name = "pyo3-macros-backend" version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" dependencies = [ "heck", "proc-macro2", "pyo3-build-config", "quote", "syn 2.0.89", ] [[package]] name = "quote" version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", ] [[package]] name = "redox_syscall" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "regex" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ "base64", "bytes", "encoding_rs", "futures-channel", "futures-core", "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-tls", "hyper-util", "ipnet", "js-sys", "log", "mime", "native-tls", "once_cell", "percent-encoding", "pin-project-lite", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "system-configuration", "tokio", "tokio-native-tls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "windows-registry", ] [[package]] name = "ring" version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", "getrandom", "libc", "spin", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "rowan" version = "0.15.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a542b0253fa46e632d27a1dc5cf7b930de4df8659dc6e720b647fc72147ae3d" dependencies = [ "countme", "hashbrown 0.14.5", "rustc-hash", "text-size", ] [[package]] name = "rowan" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "028acc0aeb6c46a4e4390928ef6ec0f4b7e9432f37bd4129a976e77f86f93322" dependencies = [ "countme", "hashbrown 0.14.5", "rustc-hash", "text-size", ] [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" version = "0.38.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" dependencies = [ "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.52.0", ] [[package]] name = "rustls" version = "0.23.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f1a745511c54ba6d4465e8d5dfbd81b45791756de28d4981af70d6dca128f1e" dependencies = [ "once_cell", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-pemfile" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ "rustls-pki-types", ] [[package]] name = "rustls-pki-types" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" [[package]] name = "rustls-webpki" version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", "untrusted", ] [[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "schannel" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.6.0", "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", ] [[package]] name = "security-framework-sys" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "semver" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "serde_json" version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", "memchr", "ryu", "serde", ] [[package]] name = "serde_spanned" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] [[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", "itoa", "ryu", "serde", ] [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ "indexmap", "itoa", "ryu", "serde", "unsafe-libyaml", ] [[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "sha2" version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "silver-platter" version = "0.5.44" dependencies = [ "breezyshim", "chrono", "clap", "debian-analyzer", "debian-changelog", "debian-control", "debversion", "env_logger", "flate2", "gpgme", "lazy-regex", "lazy_static", "libc", "log", "percent-encoding", "pyo3", "rand", "regex", "reqwest", "serde", "serde_json", "serde_yaml", "shlex", "tempfile", "tera", "trivialdb", "url", "xdg", ] [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "slab" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ "autocfg", ] [[package]] name = "slug" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" dependencies = [ "deunicode", "wasm-bindgen", ] [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smawk" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "svp-client" version = "0.2.0" dependencies = [ "log", "serde", "serde_json", "url", ] [[package]] name = "svp-py" version = "0.0.0" dependencies = [ "breezyshim", "debian-changelog", "pyo3", "pyo3-filelike", "pyo3-log", "serde_json", "silver-platter", "tera", "url", ] [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ "futures-core", ] [[package]] name = "synstructure" version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", "unicode-xid", ] [[package]] name = "synstructure" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.6.0", "core-foundation", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "system-deps" version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ "cfg-expr 0.15.8", "heck", "pkg-config", "toml", "version-compare", ] [[package]] name = "system-deps" version = "7.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66d23aaf9f331227789a99e8de4c91bf46703add012bdfd45fdecdfb2975a005" dependencies = [ "cfg-expr 0.17.1", "heck", "pkg-config", "toml", "version-compare", ] [[package]] name = "target-lexicon" version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", "fastrand", "once_cell", "rustix", "windows-sys 0.59.0", ] [[package]] name = "tera" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab9d851b45e865f178319da0abdbfe6acbc4328759ff18dafc3a41c16b4cd2ee" dependencies = [ "chrono", "chrono-tz", "globwalk", "humansize", "lazy_static", "percent-encoding", "pest", "pest_derive", "rand", "regex", "serde", "serde_json", "slug", "unic-segment", ] [[package]] name = "text-size" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" [[package]] name = "textwrap" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" dependencies = [ "smawk", "unicode-linebreak", "unicode-width", ] [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "tinystr" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "tokio" version = "1.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" dependencies = [ "backtrace", "bytes", "libc", "mio 1.0.2", "pin-project-lite", "socket2", "windows-sys 0.52.0", ] [[package]] name = "tokio-native-tls" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ "native-tls", "tokio", ] [[package]] name = "tokio-rustls" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ "rustls", "rustls-pki-types", "tokio", ] [[package]] name = "tokio-util" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", ] [[package]] name = "toml" version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", "toml_datetime", "toml_edit", ] [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", "winnow", ] [[package]] name = "tower-service" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "pin-project-lite", "tracing-core", ] [[package]] name = "tracing-core" version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", ] [[package]] name = "trivialdb" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b848c192f890a351e18ad63cb7dfe1145225441875ef498765af37d7ab17ef55" dependencies = [ "bindgen", "bitflags 2.6.0", "libc", "pkg-config", "system-deps 7.0.3", ] [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unic-char-property" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" dependencies = [ "unic-char-range", ] [[package]] name = "unic-char-range" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" [[package]] name = "unic-common" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" [[package]] name = "unic-segment" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" dependencies = [ "unic-ucd-segment", ] [[package]] name = "unic-ucd-segment" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" dependencies = [ "unic-char-property", "unic-char-range", "unic-ucd-version", ] [[package]] name = "unic-ucd-version" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" dependencies = [ "unic-common", ] [[package]] name = "unicode-ident" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-linebreak" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unindent" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", ] [[package]] name = "utf16_iter" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version-compare" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", "syn 2.0.89", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" dependencies = [ "cfg-if", "js-sys", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "web-sys" version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "whoami" version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" dependencies = [ "redox_syscall", "wasite", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-registry" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ "windows-result", "windows-strings", "windows-targets 0.52.6", ] [[package]] name = "windows-result" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-strings" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ "windows-result", "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ "windows-targets 0.48.5", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ "windows_aarch64_gnullvm 0.48.5", "windows_aarch64_msvc 0.48.5", "windows_i686_gnu 0.48.5", "windows_i686_msvc 0.48.5", "windows_x86_64_gnu 0.48.5", "windows_x86_64_gnullvm 0.48.5", "windows_x86_64_msvc 0.48.5", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] [[package]] name = "winreg" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] [[package]] name = "winreg" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" dependencies = [ "cfg-if", "windows-sys 0.48.0", ] [[package]] name = "write16" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "xdg" version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" [[package]] name = "yoke" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" dependencies = [ "serde", "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", "synstructure 0.13.1", ] [[package]] name = "zerocopy" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] [[package]] name = "zerofrom" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", "synstructure 0.13.1", ] [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] name = "zerovec" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", "syn 2.0.89", ] silver-platter-0.5.44/Cargo.toml0000644000000000000000000000435714721061524013452 0ustar00[package] name = "silver-platter" version = "0.5.44" authors = [ "Jelmer Vernooij ",] edition = "2021" license = "GPL-2.0+" description = "Large scale VCS change management" repository = "https://github.com/jelmer/silver-platter.git" homepage = "https://github.com/jelmer/silver-platter" default-run = "svp" categories = ["development-tools"] [dependencies] tempfile = "3" serde_yaml = ">=0.9" log = ">=0.4" percent-encoding = "2" chrono = ">=0.4" regex = "1" debian-changelog = { workspace = true } tera.workspace = true clap = { workspace = true, optional = true, features = ["derive", "env"] } shlex = "1" env_logger = { workspace = true, optional = true } trivialdb = { version = "0.1.7", optional = true } flate2 = { version = "1", optional = true } reqwest = { version = ">=0.12", optional = true, features = ["blocking"] } lazy-regex = ">=2" libc = "0.2" xdg = "2.5" debian-analyzer = { version = ">=0.158.19", optional = true } #debian-analyzer = { path = "../lintian-brush/analyzer", optional = true } gpgme = { version = "0.11.0", optional = true } pyo3 = { optional = true, workspace = true } lazy_static = "1.5.0" debian-control = { version = ">=0.1", optional = true } rand = "0.8.5" [workspace] members = [ "svp-client", "svp-py" ] [features] default = ["debian", "detect-update-changelog", "cli"] debian = ["dep:debversion", "dep:flate2", "dep:reqwest", "dep:pyo3", "dep:debian-control", "breezyshim/debian", "dep:debian-analyzer"] gpg = ["dep:gpgme"] last-attempt-db = ["dep:trivialdb"] detect-update-changelog = ["debian"] cli = ["dep:clap", "dep:env_logger"] pyo3 = ["dep:pyo3"] [dependencies.serde] workspace = true features = [ "derive",] [dependencies.serde_json] workspace = true [dependencies.breezyshim] workspace = true [dependencies.debversion] version = ">=0.1" features = ["python-debian", "serde"] optional = true [dependencies.url] workspace = true features = [ "serde",] [workspace.dependencies] pyo3 = ">=0.22" pyo3-log = ">=0.11" serde_json = "1" tera = "1" serde = "1" breezyshim = ">=0.1.227" #breezyshim = { path = "../breezyshim/trunk" } url = "2" debian-changelog = "0.2" env_logger = ">=0.10" clap = "4" [[bin]] name = "svp" required-features = ["cli"] [[bin]] name = "debian-svp" required-features = ["debian", "cli"] silver-platter-0.5.44/LICENSE0000644000000000000000000004325414721061524012526 0ustar00 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. silver-platter-0.5.44/MANIFEST.in0000644000000000000000000000013214721061524013243 0ustar00include AUTHORS include LICENSE include README.rst include TODO recursive-include man *.1 silver-platter-0.5.44/Makefile0000644000000000000000000000100714721061524013147 0ustar00all: build-inplace build-inplace: python3 setup.py build_ext -i python3 setup.py build_rust -i coverage: build-inplace python3 -m coverage run -m unittest tests.test_suite coverage-html: python3 -m coverage html check:: style style: PYTHONPATH=$(shell pwd)/py ruff check py fix: ruff check --fix py cargo fmt --all format: ruff format py cargo fmt --all check:: testsuite testsuite:: build-inplace PYTHONPATH=$(shell pwd)/py python3 -m unittest tests.test_suite testsuite:: cargo test-all-features silver-platter-0.5.44/README.md0000644000000000000000000002021214721061524012765 0ustar00# Silver-Platter Silver-Platter logo Silver-Platter makes it possible to contribute automatable changes to source code in a version control system ([codemods](https://github.com/jelmer/awesome-codemods)). It automatically creates a local checkout of a remote repository, makes user-specified changes, publishes those changes on the remote hosting site and then creates a pull request. In addition to that, it can also perform basic maintenance on branches that have been proposed for merging - such as restarting them if they have conflicts due to upstream changes. Silver-Platter powers the [Debian Janitor](https://janitor.debian.org/) and [Kali Janitor](https://kali.janitor.org/). However, it is an independent project and can be used fine as a standalone tool. The UI is still a bit rough around the edges, I'd be grateful for any feedback from people using it - please file bugs in the issue tracker at https://github.com/jelmer/silver-platter/issues/new. ## Getting started To log in to a code-hosting site, use ``svp login``: ```shell svp login https://github.com/ ``` The simplest way to create a change as a merge proposal is to run something like: ```shell svp run --mode=propose ./framwork.sh https://github.com/jelmer/dulwich ``` where ``framwork.sh`` makes some modifications to a working copy and prints the commit message and body for the pull request to standard out. For example: ```shell #!/bin/sh sed -i 's/framwork/framework/' README.rst echo "Fix common typo: framwork ⇒ framework" ``` If you leave pending changes, silver-platter will automatically create a commit and use the output from the script as the commit message. Scripts also create their own commits if they prefer - this is especially useful if they would like to create multiple commits. ## Recipes To make this process a little bit easier to repeat, recipe files can be used. For the example above, we could create a ``framwork.yaml`` with the following contents: ```yaml --- name: framwork command: |- sed -i 's/framwork/framework/' README.rst echo "Fix common typo: framwork ⇒ framework" mode: propose merge-request: commit-message: Fix a typo description: markdown: |- I spotted that we often mistype *framework* as *framwork*. ``` To execute this recipe, run: ```shell svp run --recipe=framwork.yaml https://github.com/jelmer/dulwich ``` See `example.yaml` for an example recipe with plenty of comments. In addition, you can run a particular recipe over a set of repositories by specifying a candidate list. For example, if *candidates.yaml* looked like this: ```yaml --- - url: https://github.com/dulwich/dulwich - url: https://github.com/jelmer/xandikos ``` then the following command would process each repository in turn: ```shell svp run --recipe=framwork.yaml --candidates=candidates.yaml ``` ## Batch Mode Use batch mode when you're going to make a large number of changes and would like to review or modify the diffs before sending them out: ```shell svp batch generate --recipe=framwork.yaml --candidates=candidate.syml framwork ``` This will then create a directory called "framwork", with a file called ``batch.yaml`` with all the pending changes: ```yaml name: framwork work: - url: https://github.com/dulwich/dulwich name: dulwich description: I spotted that we often mistype *framework* as *framwork*. commit-message: Fix a typo mode: propose - url: https://github.com/jelmer/xandikos name: dulwich description: I spotted that we often mistype *framework* as *framwork*. commit-message: Fix a typo mode: propose recipe: ../framwork.yaml ``` For each of the candidates, a clone with the changes is created. You can introspect and modify the clones as appropriate. After you review the changes, edit batch.yaml as you see fit - remove entries that don't appear to be correct, edit the details for the merge requests, etc. Once you're happy, you can publish the results: ```shell svp batch publish framwork ``` This will publish all the changes, using the mode and parameters specified in ``batch.yaml``. ``batch.yaml`` is automatically stripped of any entries in work that have fully landed, i.e. where the pull request has been merged or where the changes were pushed to the origin. To check up on the status of your changes, run ``svp batch status``: ```shell svp batch status framwork ``` And to refresh any merge proposals that may have become out of date, run publish again: ```shell svp batch publish framwork ``` ## Supported hosters At the moment, the following code hosters are supported: * [GitHub](https://github.com/) * [Launchpad](https://launchpad.net/) * [GitLab](https://gitlab.com/) instances, such as Debian's [Salsa](https://salsa.debian.org) or [GNOME's GitLab](https://gitlab.gnome.org/) ## Working with Debian packages Several common operations for Debian packages have dedicated subcommands under the ``debian-svp`` command. These will also automatically look up packaging repository location for any Debian package names that are specified. * *upload-pending*: Build and upload a package and push/propose the changelog updates. * *run*: Similar to *svp run* but specific to Debian packages: it ensures that the *upstream* and *pristine-tar* branches are available as well, can optionally update the changelog, and can test that the branch still builds. Some Debian-specific example recipes are provided in `examples/debian/`: * *lintian-fixes.yaml*: Run the [lintian-brush](https://packages.debian.org/lintian-brush) command to fix common issues reported by [lintian](https://salsa.debian.org/qa/lintian). * *new-upstream-release.yaml*: Merge in a new upstream release. * *multi-arch-hints.yaml*: Apply multi-arch hints. * *orphan.yaml*: Mark a package as orphaned, update its Maintainer field and move it to the common Debian salsa group. * *rules-requires-root.yaml*: Mark a package as "Rules-Requires-Root: no" * *cme.yaml*: Run "cme fix dpkg", from the [cme package](https://packages.debian.org/cme). *debian-svp run* takes package name arguments that will be resolved to repository locations from the *Vcs-Git* field in the package. See ``debian-svp COMMAND --help`` for more details. Examples running ``debian-svp``: ```console # Create merge proposal running lintian-brush against Samba debian-svp run --recipe=examples/lintian-brush.yaml samba # Upload pending changes for tdb debian-svp upload-pending tdb # Upload pending changes for any packages maintained by Jelmer, # querying vcswatch. debian-svp upload-pending --vcswatch --maintainer jelmer@debian.org # Import the latest upstream release for tdb, without testing # the build afterwards. debian-svp run --recipe=examples/debian/new-upstream-release.yaml \ --no-build-verify tdb # Apply multi-arch hints to tdb debian-svp run --recipe=examples/debian/multiarch-hints.yaml tdb ``` The following environment variables are provided for Debian packages: * ``DEB_SOURCE``: the source package name * ``DEB_UPDATE_CHANGELOG``: indicates whether a changelog entry should be added. Either "leave" (leave alone) or "update" (update changelog). ## Credentials The ``svp hosters`` subcommand can be used to display the hosting sites that silver-platter is aware of: ```shell svp hosters ``` And to log into a new hosting site, simply run ``svp login BASE-URL``, e.g.: ```shell svp login https://launchpad.net/ ``` ## Exit status ``svp run`` will exit 0 if no changes have been made, 1 if at least one repository has been changed and 2 in case of trouble. ## Python API Other than the command-line API, silver-platter also has a Python API. The core class is the ``Workspace`` context manager, which exists in two forms: * ``silver_platter.workspace.Workspace`` (for generic projects) * ``silver_platter.debian.Workspace`` (for Debian packages) An example, adding a new entry to a changelog file in the ``dulwich`` Debian package and creating a merge proposal with that change: ```python from silver_platter.debian import Workspace import subprocess with Workspace.from_apt_package(package="dulwich") as ws: subprocess.check_call(['dch', 'some change'], cwd=ws.path) ws.commit() # Behaves like debcommit ws.publish(mode='propose') ``` silver-platter-0.5.44/TODO0000644000000000000000000000076614721061524012212 0ustar00Backends (in Breezy upstream) - Support for Mercurial - Support for Svn Upstream changes: * pngcrush * FSF Debian ====== * Submit patches against non-Vcs packages to the BTS? * Create cherry-pick merge proposals for bug fixes that are forwarded upstream + especially for stable debian releases - use debian.deb822.GPGV_DEFAULT_KEYRING in upload-pending-changes.py ? upload-pending-commits.py: * Improve speed of verifying signatures - support --mode=merge-directive - support --mode=debian-bts silver-platter-0.5.44/codemod-protocol.md0000644000000000000000000000671414721061524015314 0ustar00# Silver-Platter Codemod Protocol, v1 The core of silver-platter are user-provided codemod commands, which get run in version control checkouts to make changes. Commands will be run in a clean VCS checkout, where they can make changes as they deem fit. Changes should ideally be committed; by default pending changes will be discarded (but silver-platter will warn about them, and --autocommit can specified). However, if commands just make changes and don't touch the VCS at all, silver-platter will function in "autocommit" mode and create a single commit on their behalf with a reasonable commit message. Flags can be specified on the command-line or in a recipe: * name (if not specified, taken from filename?) * command to run * merge proposal commit message (with jinja2 templating) * merge proposal description, markdown/plain (with jinja2 templating) * whether the command can resume * mode ('push', 'attempt-push', 'propose') - defaults to 'attempt-push' * optional propose threshold, with minimum value before merge proposals are created * whether to autocommit (defaults to true?) * optional URL to target (if different from base URL) The command should exit with code 0 when successful (or no-op), and 1 otherwise. In the case of failure, the branch is discarded. If it is known that the command supports resuming, then a previous branch may be loaded if present. The `SVP_RESUME` environment variable will be set to a path to a JSON file with the previous runs metadata. The command is expected to import any metadata about the older changes and carry it forward. If resuming is not supported then all older changes will be discarded (and possibly made again by the command). Environment variables that will be set: * `SVP_API`: Silver-platter API major version number. Currently set to 1 * `COMMITTER`: Set to a committer identity (optional) * `SVP_RESUME`: Set to a file path with JSON results from the last run, if available and if --resume is enabled. * `SVP_RESULT`: Set to a (optional) path that should be created by the command with extra details The output JSON should include the following fields: * *code*: In case of an error, category of error that occurred. Special values are * *success*: Changes were successfully made * *nothing-to-do*: There were no relevant changes that could be made * *transient*: Optional boolean indicating whether the error was transient * *stage*: Optional list with the name of the stage the codemod was in when it failed * *description*: Optional one-line text description of the error or changes made * *value*: Optional integer with an indicator of the value of the changes made * *tags*: Optional list of names of tags that should be included with the change (autodetected if not specified) * *context*: Optional command-specific result data, made available during template expansion * *target-branch-url*: URL for branch to target, if different from original URL The *value* of a run can be used when e.g. prioritizing the publishing of results, if there are multiple runs. It's only meaningful relative to the value of other runs. Debian operations ----------------- For Debian branches, branches will be provided named according to `DEP-14 `_. The following environment variables will be set as well: * `DEB_SOURCE`: Source package name * `DEB_UPDATE_CHANGELOG`: Set to either update_changelog/leave_changelog (optional) * `ALLOW_REFORMATTING`: boolean indicating whether reformatting is allowed silver-platter-0.5.44/debian-svp0000755000000000000000000000164014721061524013470 0ustar00#!/usr/bin/python3 # Copyright (C) 2018 Jelmer Vernooij # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import os import sys sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) from silver_platter.debian.__main__ import main # noqa: E402 sys.exit(main()) silver-platter-0.5.44/devnotes/0000755000000000000000000000000014721061524013340 5ustar00silver-platter-0.5.44/disperse.toml0000644000000000000000000000040414721061524014222 0ustar00tag-name = "$VERSION" verify-command = "make check" tarball-location = [] release-timeout = 5 [[update_version]] path = "pyproject.toml" match = '^version = "(.*)"$' new-line = 'version = "$VERSION"' [github] url = "https://github.com/jelmer/silver-platter" silver-platter-0.5.44/examples/0000755000000000000000000000000014721061524013327 5ustar00silver-platter-0.5.44/helpers/0000755000000000000000000000000014721061524013153 5ustar00silver-platter-0.5.44/logo.png0000644000000000000000000100243114721061524013160 0ustar00PNG  IHDR cHRMz&u0`:pQ<bKGDIDATxwde?9ܪ3 As (**"bd]7tEEQP "fT 3 0f{V:W;6UnݺussEDDӍ>g3>=>]s쇹vwuTy?Sw1c#c1G,0c1`aVe1Vm&bMrc1K1c#c1G6c|Kmwz1Lov<9'3~{c13%c1G,0c1ifqOڸ}ho&sn퍙 v7_EQc1K1c#c1G(c1czu1c1=Y`1cLc1Ƙ>b 1c1}c1c%c1K1c#c1G,0c1X`1cLc1Ƙ>b 1c1}c1c%c1K1c#c1G,0c1X`1cLc1Ƙ>g{1c1OTZ3{|߮c1G,0c1X`1cLc1Ƙ>b 1c1}c1c%c1K1c#c1G,0c1X`1cLfnaZN(*l~ 7Ƙc>fc1X`1cLc1Ƙ>bs1!6c&`1cLc1Ƙ>b 1c1}l"Bpc1Ƙ0qNYc1Ƙ>b 1c1}c1cgVi)M\ak_,4c1X`1cLc1Ƙ>bs|>eܴٜ5[gާv3y2߷w7c1X`1cLc1Ƙ>b 1c1}c1c%c1K1c#1tokwc1Se|c1K1c#c1Gl1lCj;kYݿ1ƬCiWc1Ƙ>b 1c1}c1cy>۰8A_>kϹ|?&c5̾k3_og3c1G,0c1X`1cLu1LLüYzcƳw1c1}c1c%c10AI5cl`&Îc1c%c1K1c#6lH/d a#mfsc1K1c#c1Gw.lLo\~ϳgN3lٔdޛS}|>[9?1c1fZX`1cLc1Ƙ>b 1c1}c1c%c1K1c#sbc1i{ϗ^fuqbc1K1c#c1Gl6j(g3wLƺv03j);c1Ƙ>b 1c1}c1cȜ}n˞-S=Ƭwbo6|ylk5Fֱc1Ƙc1c%c191c1Ɣl]3 1c1}c1c%c1`5c6`c1K1c#c1Gl1 !k ƳfS2f&Yc1Ƙ>b 1c1}c1c0Xmwjgn;dLxu~_̗L?w{_o1c#c1G,0c1yl1clc1G,0c1X`1cL9c楩qj͡2fu6>c1K1c#c1Gl 1f>Usl+c6>lc1G,0c1X`1cLb 1c1}c1c0f: Lu>\k1cщ1c1}c1c%c1` N1Ƙ^zͅώeW1c#c1G,0c1hMLssm1f;`1cLc1Ƙ>b 1c1}lZwj1c6mvc1Ƙ>b 1c1}c1cȼ!cas\fQ1c1}c1c%c1y?l8SO!1c}c1G,0ƘCKjc4K1fY`1f9K^ǩb=!$JퟬHH3%I = iz}@$5 ڧqmjyD1mT@jy{0Ȑ$"r)ƨ @D;"9(D+q]{x*ӱ;c&Dy6l8_^al1sW Z2p(BR9=A,˘ @EM5I ~3j;ng.7(7 !*@F&O>8OH}W1&|t!"Dģ$m'J,0sن%0_hK٘K}',h]; UFW$wBxFR GYWY(={Em_|Ŏ;ώ}e0U24*hm)wٕfYE-SN=e-j&r"@ )]HU2&cثkc,f}?̗7f~mTw%.,W 9Qx(OzmZ+Oyr'~*_Ө 1 hT @ո=@TsE77W[gj`hY $&h(mz=\`h+ vi[lEeZH;CSVXY`,A3sKm 1DLD*D0# >$I{_ůq{z`UW]x^qҧZ|e^y*1\i1UF -,70ty}'@PfAAR?Ibx+O>>pG}$@drow@Wsq4Sd3wX0,oR\py{{_\ '|;k,y,k" pAScj\ySm?- 3xk5u}m$q(9ʮDD@SO~qp¡SN9ad# "X8Fp\](2K1s%33_"B\%,Ԏiz[wÏkBm(%5އbhv * ZVxLwy#w1\hLn'4`l q{0Yrڳ,cB63|ݓ'_3QfgSړP$Wn9fm}_. 1IJ19$ !Feg:!ڵ[=[ηƘ^,A7iVVBc"6, /rBٜ!n~h#_җ|xD8.Ӕ׷#x%YW0L._49xmH+ =@,"3W]xk?^rŋys)U#z$93uY`Kf%wM'DjvpLBJ@n|?/~/SN  r~\͌%1] @WPG !<*y*Z~!o=w18FhQnLXh H*Dࡥ\r7.E@^Β1)Tz]VWr+D}-ؘ[Qpsj"RX j׻.x)z c$M}KiYc̔X,c'fdN}Y/<I3TYy{I}P]bPL1}9#zY{oee˱S'ƛ %`gs*@^ v{=,$AށT4r e~z%>F{U8xkrYC5vmClrkg :؁kl7HU <ϝs"B('Ï= /.y%>dQ[emf+2c @XM:M*c>-o~N}բy;$AZh|14c)csᵳ`6Θsh4j5pF;\w%jH`e85w-4Kʧ;>(-Pa'{֩j0XHLSY;>7}ef\f¦ͅuؔ8cjޛJEߵ~KbD& f ]A|^lW9wI,Ϧm^b"c;gdgqsAT(0Ows9v R<6^;KaS:|oR|t>.4XXW.?> A$ p$F m' F1N~Sg,vvҀ3 @YT6d"I@9McG#6c)csᵳ`6Θ@2"%r>dVg?Ǘ,,h9 XU&'p"_Ps(y{U80(ퟣt|s^wv^EZw^>nYARLMW)AUSʵs?ͯ&m>Ljr46xl.væt3U4VG9(OWP/|eYQe_PJw6(+bmj^af9*ADA 1BV䍄{9<<䲧%u͕рd|!ڄ/"x0YS* 8܌ iLw9pΧFcѨe I24P{Lx~⶚6c)csᵳ`6Θ P8ۣӫ\h" "W1QK3]jah+W>084`xx`pgu#,Ԉ9hǸrmRpSR]Q4DD[.H#kh䝼ėǪjT}4i&lJ\x6V8şOcʹTb,|1,+|/[Vؚ^9d}7SNoFIDTPj 1h;AI. F8nݖ.Yrq{GqsđG6Os:Ę)cQG@('b_}6' ?̂5#7ܰ;vۭ[,Z9 7@bd &<3ښM{qH&~AjYK*H? oVX}"%"x=siݔi=dr mV>}LVY^hĴǝpH wu?͇ztfeVc&e!"b%XDhŊK.cKj,{jCeT%K#jM!`fEk #;{zNh]fOM\%7^wKֱ gTMI9G??tLQhןC6ۼVqYVg}ye'"8%1j6'rc׍,0kH˄-DXU9/THI#J"T"Y㼿77xr)Rz Idg7~Iw?m @WI ZXw۾`@$ t`n :aS:7K;1mwJr"DQ2бuBX] ` X|V=;WɱBHϲ#j~ 4.c^aODA|S_?h X3IRTju ,^/ya m|s!.S=Nq8^kK1=>aq+LDɻBTAp|#KH`Pos2&K"8"eyz]wZ}pE^5Xdl̳`zWK ]Z03$UUu"y+V,_GYl9 6o8FJJ/?u_75X7 `~O>#t$&T7s-(Licz >0Y_?X׫1Q75@*Ʀq|*bu]|Vo#9NP0X$"vj[&\`Shie):$m[yȄ~E 缪JL 1HcҥwO F(]]MbOoɫK(H>??} Gx/' \6ׂ a >ߙf 1^'yl4) rO~Ǟ1y^oU5 Q]xwfm\")/I4(0rZg-/(o9DHy&+H1fđx5>׼_i[w&g+pUU$9cO"r=hoUpuII`kAᆰ`zLS(`b)?o{ǻ<g^ * OJW?q{ f|;8F9φnhjmkـe"R.U4{:GxY;5Ӡ (,R(1 3'eY,rvjJɑGׂbͻm%lLIkD[||#+z u496VkO6O{3I=EG1@GDTfS,ZP!,;,0넕'QU9 geCIy/R`dZNq0;/r)@=IB|Qֵ @ЈYD0]$nV)i1!)H1fY&{rNf,J $1˞z޲t(h#NEQr_M6o憖fpy#!/:??h16Y͵pCX0wY`iعxw\-jHARj^iJA6!`rv1䂏)sND.:vVw.r2/ 8T#UeDRr=e DUqޗ ecJQy3!r+HmސPW E,cNUisםw1YhBx*ʖ6^@Il9N~Ow/= J``k=w̵pCX0wY`i ,cz'gN>+R5%M:ǀg=Vgt ox၁F.3fN),RUM"IB];y $f kyܣ[h4y/Y;>yѺQ/~Ʉ͂;"^Eof .,b1:2ZĢ^###"lp````@eޱh1>DB]:LWU@5!j崮p克ZVrS駟妛-}#$U`EPMϜ 8 e}NUd/:=pHk-;ZP!,;,g cnzO(fszHB/h Vw\wÍDIBcrA8N(wb11k#h;~744,R(,? t 3OSW_ډj$*܌(Vb!Ԋz|"χz~ϐ$<@}eA"䆆8ϗ!;o;Vivn0:nʜa]vv.˲B,eJPjsj UƉz7^[ϑru c*Bc_?7 1A@wn<\H%jxkf HP"ƔqSۅR̙kAᆘk og%%$(VU!r(??x;WswkbDYmhhw\ ֶs#j̒$9vD.Z8;IľO?#]fŊk֬Gli/` %8؆D`BL~h>/ o %g;e(ԋ2U7c?>`^E IιRYI"|&*H{u%&^kOgQK "1&DZx'o'Y1ƆFÅL͇[vܱqGQF|CC5EKU½7>GfΦZ0X0X`: "%Wцÿc\m NAL$]kaQy_8{^744TH"I)8:<궭*I%IJ(y5=H~\T]RP|N`zKjBy){0A9Q*?HH uD! HB8Z[cv|6"":z ʇTU'(oT^)A5!%bg~AX4F҉к°QFA } @2A5}̜M)H`~`t@W>?u Q"wFs3gj}{_OH"`1';khTW$V=HJ o蕯z$4/@ H|wyt (zDM"bҔ]չyfKǓO"Hi2 >I^wS ЪHcQ@ IZ >,bEu6qkJWR= &"n9(;[n9>;.RIJ- !HXzn16(BP3HQUBP'@5 R^TPE *R5wr!&*hLV x 易DHD,(Zgb>Z&h3@TUZOmvZxSW-BX"cLqQ,dI:L{LPΉ}}vgW<裷-bB - 8G}nп\%Z{>wǿn;lN$ IBǨ5? lL@73c b](@`MC~Wap3/yEtFy)ADl]wDy]檕gT=z^< γ_#/W=4hJ硊F@Z֘0AD3Z=?r({f̿Nܒ?L:nױܲ'K;nG(ǮR|ZbYm$o0;ȝkؕ&ӑkc;nhm<12z5.] A!J?cN! WC#z{$S1K;K爋#)ϒҳk˗\~:P,g'(yz=+Ne( x qq3P!qhL()):h#O$D"HRJ Eۖ.}G&x"GEy=!&.R1 02$ 4&zĻz:ZN#Sժ\bw5gطZ*] )pl7EM"2~򑇞|\mf-r 9GIYbgkΝ-@|Vh>'tUY̯~fy^-QDro0} ܞ'OÅ_oO>%!s.E]wRl1fX`\ o'a "ME@9qv';ߺMմ9됃{EpQTyL%(TG4 I3rbTZEǗ>z5נ(@ Q< H˼VjY<$D՚ \H3ƚk eT Rz1O>x׽b7_^{UC'eqԎZ2z}A,1fhy՚ܙ*̵G3O|ի_i ur+H,0f^`gM$QIHjѻ'_y.LB"fj+AASюknH4PO@-dDO硄-:vqr3"[LeCib EH2"_bE>y];U\QQ U_ͺTYYCH{Y4q5帱k%( |jTSz=p;$ؚ^+O&$M7?Dk/F"gxBGԇd(lV9mGD@{09Spvc 9jT:7} ۉՃIU+{׬ZSO)*Um rڔ)ßx#~ e vW4Cw{uC˅8RTu:NЮ4*E}} v83#y#/$zc4K'$hVx;Ny)r zS,7r1\s`^vV[)AUHcɴ ㆛’$? G$2Hvˍ7y9kLp2o:'1,K1K|ũgz꫿{o>Wx̆Y.`|d 1}M,:e>x>(bjF튂4_pϽc}ʤL1(´^(ܩ ###1T]7c]u6[MUuLD$rQG?vkI""p}ʫa׿'2@[yL] 4l\#&"(LL΍4rj_z^f2ЋE#z319(,*BX^t7[B,/G$`'ʴgw"M)Q!VŘyZnRKT 2OTSԱXM*T; t:-77;mǚȺ_[rElE\Hs_rϽ{2vswTE#Eʙ3&թ!&iyppU{+Www|| 1g#si#f]{BƢ8w~^/ <)FNi|-'RυZvcLc6qDcdb!DϮqY+Q=e.*CQ/~ 8" W6TU ٩}VDԚY|Ԟ)<;\&$dڞWke.Tum+CwwՎdʀV/#1l!G%oHbE)ȫ6;> Pտq}ot`+z( -f|e 1dQ!T$}>LWqǃ1wtv7Jp1Ǿ60(0꘣kC)JtNE'\>bV(er (ZɮP@rٕ<5D˿>{C&UkڽEc:Kٔ !ŤBk^{ m&)wމ$F9=! A~4Fv,E2/pp)uak^c+-j[$bR 3?أy?j8awe',(-ꭢkɼf~G6nx5^ Ee`z򑇾p~6[B9tF*~q"Bjǜ̧=xwCp^MUg:3l9 Ql4\_ϟdqa&1fy`|fGݸ]hrWg$FN媫?}9#BIU~}8=PyAIC5q{eU"U<$DZH O=7߲bri41RH 9 1W wRNuߞ}'Tʥr@z=Rֶ=j.xfZ *JL8k?z\ZIqjso h& q#w ^x{5`Gc<5X{e*qj_nPը #N .8䐽8`飏?GT=ǍFjL z.@>^4}]c80/39yj0X`9 OJn8#=(P!5 Rw^&[KDu~eZY$)e޳<_ɻo푻BAUs"՞) !(:"_$ڕ ~kmIsF+Ϟ Cnn w,γ"2Wij$4u+Aȓ6m?ÿ! K,|Lhpfy쉗>K = ڮ-4y3/5C!X*e+~3:2\t&_:`%">bm_qI䂀J"aZm^g Jgy;o{;XMVER4Z,t:J7Yhٵ1L;A e%/n׷^{V;xan󎘴bIh'x}VT(<~>}'PuдilhO|//[_m40Su]0f>yWn^D,9{+ovj(`v!Kif{w Ir翪IlO>) sR3.S,yn+E1J,HL/yD\\LjĆQ"P鲇G܁G{BJJRU[._.x$)!})2%ngx.9p)/fx|v|{|t…ל"8CDU93K4Zyc?aH\"(30R6:I CM ru̱;kRB)@i$es[n1&8 L,R4ܔX;:7Lxx 6@ʑp(/>ت':2/F9\s`]V &-戔$_^s7lx{c:i1VxSwW쩇JLМieGZB?|TҺ~ 1ttpg_/' >F"uYT nܞl&5&M>V#8Ms.Spވ)A4e!)4o_Ǘ}7id EQx&Qӌ&ZrkF}>x-6u uq` ƌ^dH RbЈ+>o}_}wɒrU&&DL,r+N~k>{#2TmGbLB鳟z} ;!{0\͡1fQ]c^U@ BJAF"V5t=JEKZfQ7 o/U-j-&Fȶ}qD>DI]qޝ* !IȚ[~s#@QvV{&?yyQ NW?.d*^j$(_co8Ḓ$]o.bn}o&zI'nx19ĭXPn&X,"3bN{=G+2f^phQ%&"Wwl(Q[u52fODZQ|~k^W^ ٻ uM,,5PRLYkjn+oe݇P.ddk!\{Y2[.x{0]5s.ƜzQƨ`{۲blNɗݤP%g=_:o}+!)I1fyV?("y'.|<8@DSvj P~'n^ U}wM}tۣ$H##P[c?~ dI!EOs&CD>D*T{{fկqpZ-x1a9l)tSO6cc{O;k١ȟo޿d[CÚLF'qsX̬e![fs~w9o5>r,eRv~AwQՉHeڝ>'?޻.F9HLj1s% =C::w]Y.ўxqUAc}900';EQd]lqt , X?ȳ+(ẍB%)I' `n"2uZ =RBk6-S o-f(w/[틿6|SOnsFȻ }qG} ښrY|3Ӈ{R`BϬx#_p@S"b4ff 1`¢o'uYHvWVM1|]sk(W#Smf8UC 6WpfB+SO瘈V6 r?RU =\vYS_ЀHZt\ak;Xvm]?܍FCX`cT1]}/XoSe3 1gn|]VD)Ev6=Mqԭ]Hiv;' XA }Һ&+2#]F_)Eh12 A3 LamLr Zkf﯍@:~^ 渗l- "Z=T4ƑǽE/9K/MCL`Wv=zNӻuSYǞxq'gjZ=l?m;lb3rkl^1s%!DrN{D)Ƣ^#T4e2jj=c_ragܜy\N\`aP`7XφV?Y MHs Q>\yFZLb2UuN}J̆hU^)ckY~E_y~cRk1E*~y'L ~7Γ`{Ee\d٣ ]v`=:Eiλu "չĸW{[kPBm"`RyZ.g>>HR%Գ]_D N9~;*O1Z* ,sc*9eX4]Na6{r{ݦ"QߺPfDr[jO}1! < 鈃;JV֚lOДgL5Gh̻?:"GE_Zݶ/;CQR*2Uq7s$J,)86kBE9 (BpDTH>nmT*R@r}\ucQ $ ph^ouH&s._c&Ϯ3Ũc+C?7 X& J1&0%^8|gȣ(q&\[@e 42y+yr|`2(ίni}Ղːek?& ˋ3~}Z8c^|ZB"<}:X9#RGZ@V̇5h{|M3.EXы}^ۛ/?2|df!=[[WK\7 sq= Lp꒺c? 8%1'EBA3,0fq en;Uơͪp uϽ(`&N)9oΑփe#O̪ Ub#yOz̄~"{mw\|O<+9rz+]"$B|/;㬳XAJH=sc_ا gu峅V1%2v\"Ij+~rԋO RU1A%3e'ׯu9goye41Ï8̳6.c_#מ/TұbQ#9Һx=Ә,Cr0rI`v=rNd6s׏UawBL~1acB*C!yGҟ^˿yY12XbpX!D*v Hv  N}GR8%g&''WS{rz7zcM6fKcF<Ȇ;[1"R(Y>l5.+85!<$,+_uh %vDꈘC} a{H~֋jshCfD`Y~酟f=~1 e>@LT۞{|k5@Lp3tV3}pn=o^cO aKqvTkM+BUcIi'NkD|F9xh_ri? !*tXה\dEW}xnRmEv1<'fYz<ӯ?Oŕ*rח3tT *O.y[}[n$#YV%KI\C ZY)WmHd== '"zЄ)xi7!U|46L]0fa4#j2|Î}o>*uĪ~Ƙa z~f=%P(2n#96xE@eJ:aB]7T/{tn',=o$-{P5ĤH3~uBrA_Tٳc\˹NWeEY'Jsg0 kr*5 +ԇ~LS[cDP{V!셇^˖- G?;MPǁUL_ jkl, ݫMy;ѥ_r] 6T$ >U;x1s`4/Bࡡs~뻮 T RUMIlMbR"6;o_a - R9T]5?׬%F9.@ X Ƴޜ oF`Rj+g'5/N/g N }hz5.y_h@ Ⲩ]v7}%&r̞LZdz>7ɡvTzk 9] eIi|1FH)wg" RVr(ЄF|З|*R$U qy) Uߴ3)/)G~|1,(^؅^'|;ݖH, 3Lx3fYJ HUB`puMghє4s:`h(" mhF1&/+nbqXÜ֬X~W/GN'Ժ瘜s̎=+oIpctPDN;wvkN:nc!(I*@ic@fuj77{AX XFcs. jWq2Y|^!X&IyC_[aceV.dO0fnSDHnQǞv鷿P[=hC9mi_Fu%{'Z 8@k~q_^JE,&Nxi,k:V.z  @v;m1D1mbͨT !#h?sֿ؟qJ]+muZ_R>q(i*` o|/WyrՒa=I(_~ɧ{.| .pV#v?85*I_o DByL KiJHD фL1~/+ yx?E|UBQ@s]ѓZ=i|%y2U˟~$%(\L">ƫz3tjoΫ=Uʨ~[%`j+®,Tk=̀/c@-|K>8WӮkWUM_ q$':#:<0"RBZ_UF;^æNdC=\0bÉZ&`Ԋ9rH]`@>3?f\hcN{V1#|o `F\{fGDǐ KNZuZ]" (sBy@L|+'+WxG4&鿂JGoy9ث''}XDz cxp{L'Z|4f . ކDYUZ y3fw$aB$DdY{kDD P$/t|iJPV!zK_-eD^9"G#"&4jijx/~DZ4+ _mnf!Sr՗UvgWiETwsZ=\}H5n/djǤHC{3^~g\m$UMQ׌#Wy-h)wM jiF}?ÔeE4*ץ_'RYOlUZ[MU5 +#Lhկy ZN:`ru5` wW~\# /?e@!DH˦Z3 6D/\r NnC"oؕk]g'}v;3f%<_\}Eh J{o|@V,zfo~7AHUzB* n* I^)"T;DQ;*;\4~*9)3ic] I}$ |"V^Ͷ`f8դw\93Hr-+]=$M"I"HZ/Vc&c6WtHAv]Nc@人 @ Og+[5<4syTUc 67%ۗDN- #4O` iR)%$A:G}e]xοG ΊZ6 I꽏1axx$u~`EU)!Wg߽< RfFGGL>)v1J^.'[Ut4ETIQBug~)<T|1ATZ_-L/j.cʜO~x` ' ,>J@^NVCSxf}Z@Zv^cO#(G0#|2ۡm*;Ss_QenhIP(];nGz뫯Z0Z'Y~ooy'#iRg%[,:W>m h]kW8\"_Ҁ@x_tщ=u.ZQq˓r9@?hsqE_^&rkA %4 'g߻յ(9L%Ƭ$D^ /}?,(XD 4]>A "gk`c0A=^@Z\>1y1QB٠?dΟWՕ ͔)A5*R8;󊕫{_t_$FD#`Hi~+՝b$ẉ.FG( l CJU]x}^rQhS@ %_o%7]t<g"9I?uW\W{XQ$G)>{ElTu原 i!2 144 "xbI͏>U?Y7KY-*ʮzv RO 5^/|ӟ9- a :ZԽ& "E+|5W.(CjFF~:0#3,y```(s˴\4@DZ W4YC_gLi >JB I_~?-LUVd̄Ș+g5'\ͿkCEFe謬 >7 myǵUHjMWb/};ՀoB@^y]~Tz9K*J3ߘX&BD!f{g̎,9dddڬB#_)U<ܨ.Z3F10hE_瞧\ o[^䤬*|搗pa6^2q]~"z ygkV7O>RxJӹBIyvɯ_rL#n^I1cq8cvOUE_ ޛ ǔ8h9M o2JUk%J/#}{) hU_~u0l'!A6{ETU\\@bg,]') Y*V^+',jV5ӏϔkO" XB5d=ꬃUȟ| j2Y7bᖯ}sVEbe `$(9QZ/J'x.GۧWy0E`9 4͕Ow} E Hznm`NR1Pw\sh%3UpHрf67X0`v@`..u}>Sh{sd*Iz &kFpW>%%VxoDU! дj_ߑ U8-fPLb}Ho?-  #VB'|onmR'@BbzcƳw1Ua*GwiPK%j,k^ 9!Po(f!1+UeE.}*ID<7}!EbY+,뽑1TU_`c<'y>lٓ:Isf2]GBY_]s0HUrq7@|tb%σ#yЖ}?}G$c T98D>׿uy?t^DB=BZxRP!ubtkn:2C u*uL 0@!;~C&.!9πժ*1O~)u O3яl}JdeQ2P.!JeQ0;IR,@VG@*Lh}L=j#{^7fq35^TD H!g<%Ϛk.ղE@' q4s{۪6e| @ˀ yL׋ 0aUDP%E:-o̤sT9\cLeuI ʫ9yO~wnAeUH*nvr{|Z׷M3] Hx{<1JOU#9#횟N:@tۗ\r5 ,LX!}9yU)^}ONe#H.W2:rϤ?8<=72<7Wh{gjW(1;qjn!b""r|3ɬ@hu7͈Hckq_g@PNy₀$>Tr?Ţ̭xCL`h|٫NqDh2l[SZ YT5 |lOg&P}޺}qղ( q̂=.ԇWl9\+xU{|^hKиTYs9˖-tm}+q`+_Ͷڒk6Pol!E8"*#J_+PO_6xǞ9fU !p>Q(>鿳p#=_E$B,r(QVF|jQD o qTHs01_҇BQ@"i5+(˫Tkh z e%* '<ЪZ $`dgV7p‰^{N"me*AÅ7,#yHLre`Έ>dzu`{UrS}A䴳\&<{?~QXN @spsb{$h93*gԧ>]{Y@c}^BFd>0/igu%YZO |/#*AHUq[5o]]jypN h1r׾2je1geV68N: +/mۭ7;ŗ~'3?%+2[Co__l0'sg4QF,:(0xO&((m5?k߿]{2I0"p"" ŋ9~(I92|ղ'WqV 68SeHr JZ(vD<)H0Z(t&r_3j3֓g)bdAOΓPUH޷j:x@eA'MISRS}A)CPW`bvݿI!/׼fmw((" $9J[nozw/:Ƙ2UUIՙj,*$ f7 uu'nLAjyT5Η^98|ge~wO8v=yy3_7nHw$ҊԹ5\:a[ '"@?;8jZg!я̐$ RǺo4w({$)G8%bIz}=qA`bgRVɼӼA3HDQDGG}w7T$ZhiT^QD!#74xghJ1)V n8:@A?ɿWrhDi|Vf]0fIRcO,{)EJ2]+J]!(EBj^fÎFixQND=0f{ş|/fkn,`’[8| ^fA7%fE%v}_ܳҹUUn:8IDAT;.;Nh/_bE$L?{'Gqt_U)$r9`21qpğ#ƁdDрmrD(Kw3]1{{;I';io~p鮪~J$HD,",,Z*#FKj##zZ۠oJ`0QLLO=ֻc:h組stV2T wՍ,b2q ~Fz"`5r> ݙ 4ph 6uͷ$k$ƨU%"=ERm ʵ*(^rk_G\"p 56VUWpFIP!+jP8B<dѷ)A.aj%XZkzύ )2Ȑ-5lhgO9e1b,Td@5ɻȲ&XAH \?*2S#\MŮ#"l|ĉ'&@yI5Cϊ ]wcڬ̃sO ,v\cqw/4@_A(U>o>M #k)Tw{!ÆP5m^m-Q$^~z:"]\_x\#c띃LTeO:5gHaMɠWz`i Z@}KU#t[K$^xii}JÇ k&-!!">5g"@C !!2oH R׳S?1㎇_bFmn 5Z`c4,[m7\_ gBg2z.=O.Hd@}a+#Ej{H]EMǨ<|ǭS> DAUN|w 2Ŀ/  M(ԇ`5̿±o/P5D`պs=}׺$P08{-K"%h6Ub>k}W^})d)HC-̈́e,Z 5W_0jvsqOdnaSmFrα˩5os-ZdLpB<`S;?*L%d_s2y3w#7<%xvlsVY\*@e-}-ԫ7@?@g\4Re%P9D1x0QRI}K㒺HFzai0y/Q9o/~9/ư1R 8{}q^v9oL`U"2f܋pFpWV BzZքzJ)HVA[6 !Lp 82fW]u6d#x,Z9|{-. 3m V:Zg%p7G0q 2L q5Ӎ]i"@e84*9cfrwo.ԇh4v> ږF[ y*ù}[ I3nn`GuIFDFj+?#NHiǰw7µ+ >K֚. I皛*&!v&1{@w㶛KRsrTO |aG$' +ZaQTzᤩxR̜2Fg0`8HY[}z^lJ5EF"!WYV6&W[k|kmJ|Dfegt=ʉ{ cMU&R\4O 2}׏88e|̲3f'|b! A`q$!H0Ɔ,(m+dZrL5 9K0pU¾ JQ!^Zsl& [G^c!mA ϩ=ޗ@AR?P@u9dosF_X^_h8=@g$P3V]gS=u\8QzyoAm鮟ۭ-K$$#-!vTR.hNҪPGa U_{KЮl\x%8$s.2feg}g͚~OZyW]y|""\l7Zx>Tg6Dfj x)Fd)wϗr)񀁙x5Fv; zB5;= $Y%*4K_k@LR `p h8}= AqLAH}?ScdADYqa*ʤ^9/V)ejiErS~fu*YVw6-'D]ilkLc( E&C! ZiCwRה$jK2kIHQuUT,w?Ь:Rt` ^T*p7xM_6hvuɓ1L"Y;I"& TG96 _S2sSTt>kE>fl2ujiV*4MZ+--8}+Ty-| z}/jmr5=BLnE΢-E=>*\lwkY ~ wyuVxw?/FDO =ݼ/8g-;glT77yGXS4}̗@"s h\&ӪmNMak 뭿zY0A1OVNKc[oѯW*dh'U̥1x*m(vݔN4oC4*!_enƏ}{*losFXmGM:3"jߏ?!G tn"v0 GC_aϽsRcut5>8Sב &cFnu `ϓP{&"ė ?żjZb7ѱrkW>{-υ34>2&R~ӎ-읋EMX| j0(1/DmT|$I1J*> 2dv-7 &PeJXD0 WNBc'ʫwm2c)yXCL* yѷ֚tj7E Q"YfGIWl ̦rj`*t}~wӚW_/ρId~ `oOpz1;t58pĀ"O4d{_`N@n:VGYܝ y `&50*~`c9Er}ӧO?gM, n7qI>q WU"(F6 ⪆ Q#ҀJ.%U}۠,PVa,cL䦒vLAcµ^x*,,}::24]jF{tܻ\22Wf c`N?K`cPزbĉ֢Yt w'|+ ZߛԷ!nnq`^Abc~i .85sCWl,H8/BvkvuG;1\ c*ݫ$.Vݳ&N6`D5J|Xi 7p}nm7d-(3>l[uPTiċ~UW_)&ȇw=#]sdlmH݆mVy _$妩#KY0HIWjO0tWs6T>{u9?d` ZE^^{^c_:t?Y@ВmQĥ)"*?jD@?7 JCp$b+.^U izi/ tp✔nv%th.o_>CUMd,˷s 2DDcnj@Twy@;VrrkDE2*w,"2ƥʥB>56˲Ӯ%*fM6x>(8czr˹P.:$$!jMvnkṽ#Y/&@AHs[nW_DQX6\ůѦmUOZn2kzNb(@NbBkסcF}3S,hLVVi[YJ[Ip.Z+Y"9$)%kh #4\V$7!*j=#vnۆ(15kg!!5J%`/[47-g8'0 D V&e"Ӭ"s Ul?'@j , T*Qd;z}'z(J,lJ6w'^;6["_yԚ僇4@R{D$!8{;h?pVo@R?%]b F +o^|UA˭ٶ{׍hJPͥc]D,p{?{<01*ޙj}up T՟8T;gѣgNLģڱPXwٟ$AV|2C^F͹+ܩ6IO3-=<,9% WQ\~h5Ի~_SQYs?ѯ>±Gmʑ\Wڲ>T\%N$Is5QƼ5w;n5t JmDQ$l@!zq6rugh܇ "1HæsޘXHc6x胣nqt&S{&6[[;d|!(͏ $;t) ԫG4Rc9F9KJe>ԭr1T)0;+aQ6T@2Rt@,Cnp7+s7m'f^~Jg{!p<8,sUZ4r{b zf}IE|')J <O[ ".(ǂM@JM/~9yAO9knKn@ , I$d>L6s{}>NEMshupw|TgH /B?ź`DCL6T*At g}+\X ރHU$]d$R@&5} IvQeV} 6u&X$IRS!D3;m>|ύ qGQߌַ{w\]~*q0\dx[o\;b1_fy|=<Y5^{/}sٕSWWa* i ._znToխEA養[2X˓g| ډ5 /~ lVaK"򤚬R αy|w.P=?SJJ.h%M Z@\ΘY9c$\>GjX 14 Pu1%(XaSG \pC;> RDDd-Gl aQa_) C+޶@P9Z:hNs\r3PTHҌD< 1}=w€TH|59{ u Zf-i-Zg͒A$;&nfiBXv.FXsUBQr-W})Jk6Hr˒ ʧ~:'_룷f0k~bL66Zkʃ]v <$Q5P *0B?~rRw,z䭨 X0k%j bH[Cbk@b-I?7XkI?V-SdTkڧ-'H@%KJƦ>81^2r6Г&|<#Re!r |yV_( U] I!\VI"W^@|bAUH@BDECƥO:M¤,2>Ymrd3se iHCׇC=|I唋|K#RR>E%Up9%{URvoU(KVGJ?7i{]w]Mst (Z3c{tu7ܨ|46b OYqĊwW#k=e|!wWt±G3y:Rq>r9D2'o[ƻ&y+-7R\J"OyyqLE(S5ԒVZ׍Š;MzՎR6i>1LتjmQl<+t?|`J>@/J5"F8h@niV"/wSU._K3 'ɦk5;X?0BT,yQ0u^_P3]) }llS#V8dpTjnn^mUփ dIcj VS, U0`в+Dթ rC/"URh锩{9< X+-Gc_;Ch)J;.(@ h)J+4ren!@Bǐ CQh\f&ƛ*ZSff" X~g[v罎|sTaBY;:ivS>x턣CȌzc>To>b*96}U7T@Dr6aXJ,sٺCTySϘ4&I gz YS#:5(L s~=$ോFǟ/$in.[׮vu: JCwblލ sXz `IR*fTWlMDlѐUwyL6 ";<Q‘!'\|m9;W\kӳi(ZbV1)cǼr, 3 X#A4K>8722}wE% jM$!.[!H]׋DQ%#$)#@K&Hl\!~YE]x$0B2E@xjoOpr9&.n ^xjmRՇ3gx|gλ,sfdJNAvetS{> ?<_}[ٰ @Dh`9F%%y&{K.!`K&I ؽi hu--- ~WeE)Q Y455 0\ w;\yM^Lե* 3 C8N?k:8/u) W;ET*8H'Ygmd9H|:'On21 "r=Ħ8D~Wx|ԑ?|k:Z^+o/ڲF.> СC >l.08Qxyu0DB0uG$QbC[+BJyɻ? +AT2T,ksoq)6]+l!7b~<6;D6'w N[yd$U ҄m`LmVwq'ۮ/S@A ^yU\@YeYAW)O-&ù<ÏzD.?[k} !OC! >MSU L [.X~\%eTR]^AFiuNTwAA6&clRU^b! ݻ_CpΘP'a^R*RT(#o5{ҝ zNErwŗ:'Rg?$Y&MO}+:Ӷv?\///$v^BH5l((eYD"?;)`3# m (6X'e}.I!,`1oҿF Dg{G6 .N0YOh \\aIo{OlT`y61jt1cqǂ(|4DEAHa| ήAU)xR4{5d "(g}}\47jMApU\H5/nU`*^5ٜD\8za,meHǟMI\nnn@D>6JƲ(a2!b%kuI,`U cQ&a&@^8(ڐS}覫GBԁTԍw5(: .0F9}g"OrQ (HPAxWVp۳~SfCJԣwCMtC BoO,Cvfv.${`"n*{f6 Hȝ*&xu7"Grړ9*Y*mĐa%#Q>Pi.ՅrD1\i O LbK fOQxZ~NX6 l~ʲyqVCؚ@o]ku?V5ͦ>t v]9iG6z@S)7 \s[p u];շ'zCd `e "NxkM7 xrTz] Ko8 .C &[ #hwȂ>3Rk6Ec6DAU0O2+_D* ~B cY6lRnVN\R ;a(GFyX 횞1jLȉz7iN>O u= 'ʱ#+눝{b# m>oO ӭԅDL/,G`QEΥ(T'OkoͷW^wY+G6Mlm@!  ac/6쎛q5g FѠAz"ii>y?Zc-ֶ,H[TމG~|_~1# y??[bǬtfθ -3lSL)4mb0`qCm$Qd U}oĉ];%ĩj>oyw\ieI@@Қq JϟLwu|:yYkBIxʼn$LL6՗_Ί)^TOZUfc yW]mM7IL3X/,e]kX[_~.r%@"#0#e*.ׅ5ly$(VL Jaef̈́2 Pf(9φ.1U*U}eW\]gMՔ8UX/jVYӿ$5`f5Hw]%b F^+-yɠRq Mqd!"&QlV9ӉEP( .s-q E 4}M]%/@y;x vN򌖊OmUΙ/ID*y/ D!X 6vڌJ9uV5E9 o(/?<T$tw"ս +f~mڜHyШLj!C֐t)?DkEB3ݙ  D ř(^m5-T, !%N= İTgDQk'SfCBTT%L 40|hK3rg ~ʼn'Vb#Mq ,Qza6g3FqVa&LM7=gx@[kClm>:ΞkfRU³' Bn#GZ "__ȓ=S2yhNlX }4'(:k,[-}-Wyn"?]@뢹urr,s #(P'm}זdj?lsӁGʫQI9l!({ۥlȃ{&)9~aׅw}Z ;?}7w^y]f$.̩ ȐV,(cFz?ਛU^Avպ%&2LD_ Wa5 @ (a#ÏM*i`O?J\2(+n]GJ wͿ.~u $ PEo܁64"! Z$k YN=qm*3Q\PM擄yl0J??yo'@`Amڰ7S*cx0{C Gp׍2%N*$}xА4l(A`[U/频6 2;Wx[!GG^w- SR |nP-j4z4"\2wH @bvf,XLlnJeaJF guk|-aJaKvs§1X2AB>C1xZoMr駟;ñ H'.zH!ów_~:X~s:b'S(*g.8U8߮^D]8|̻4-77\{տv~VZR-_; PKI,0AH9\O6m믏.ʷzEsF5Q 9ufn:t`Cج o䛢/ZTM ?]au8G1%-v1>} i/4po  U{Bd:DRB{Y\T:GB$%s\PK|XV[D`p3*qXDTg|q Aj꿬%"C$785#DU `E2Ȁ$&FB}0b{ɗ~2P*PiWno!2>/9e 0F2ۍxLPz]Ҋ-xg=#L5>\?ȃWc){>+m!n A !f[2꯿v܎Ya;&~:4ٿݸ .% !=Y":lFO>'Zk Ȃť|\u#1êmPPZaSiM @UO\v>/G&&2^+/?'|8*_/lT/?K ?ݖ(Y)'p xxjDCY1;kb ._ pT8NpN +:s2&"Ymkw8|*S(Ec}.jjswny5yn@YX6H;BP\v"`, bG_z,Tע|C 'b⃅u]?}sA|46;=v\k``XN= fSHI_U`AlzY#_X&qf <1Ta4=Nw*lZ"* CBlCW"G_l4s̫Ɔ[lV,i/ETF;7m2">% _XB| ~k%wt P* [~UV[}u;EcW2TU![%sڧ)Z*Ȱ [eW^9,:Q룪J-> @H$W5fd"{ ̊ν8'wznwNs RQbK-3[e>q[m[*ܼ"`#Qmg>84H\_=Y-]װ~ 8` x.+ C;_;caEU0z}ب*1c]ʛcO9k2&O'egݥR$wUf;o2i6\ʠִmm57*đ1, l"h*SH ,u.΄1`{mHKQ]z=,>U Ay*BniJv]?UZa]`k5~UN(P8'vQ i@ h(%K/uL5Y&Zu-}u/V2$;$rbwTv ?f)5bӳݰʽ~ewfc /CUD5ҟ{ΰ(P-Hq "\Y''|3CPSWa@K_~{0KкIn.63#c( rI'#[MXpXDZێ;錨42X&3GGe̤!DƈQwAi;Фy 20#JM Q w,;7;,ADPi yRAQҚI .R*]cuM%I@}C 82Vo6g曯o]yݷ!40ÇD\831bJ주OДW'KRD n+YI2t76ŵe (blRd7fTk慌^&MbUno*&ClTz>BZ2\\u*bޫY`R%H@8/Lə"J J@vewQs? ^CiW^eUͼ@ZH$)'g#V~ c(UE'P+CY^Vp֘{{N(veԝo[K|aK l_"֤Ae_ö6{5b;`B]Z݉ j]|7;;1F`3"5UVY&<?P:yr-N H >k~6d.2o$J^RM ZV {;yhbUT  }C*E*^xuǟ& ^$t|1 1g2[1Y@*]sAQYbk" mo6 k (}g47߮"UJ/LTHD`!@(6="N̚JD&lv=jV.5JA?O& !-o4Qѱ[\FCyϑm4J2ǨSBQ:9D坉:ɖ6h`,%BdMPqy4i~p^6o' P ]n8ð帬EC{~&r;K sƼJ&"  OMtu˲LDkm#̜9 wCTC@ A!Aa qo=eҤ4m23l \Q\Rx9D>uw0Վ;>گ.Q)E$-Vݶ?kʳ8D O~?C"'ly jTgSpx.)Pa#"  }>f>MۂEd."U԰2+ j6O$!C$^!IR/>%KfC-M9WcO7ޚZAU֫di!yҐڣ9KGwX}nt50b ;}bY e%TV^m5 f6m, ^-ySOg2L ԊFV?40gkFc 6/LtMw%j2iPi 7IVrG}XˀH82B2o춨Y/z9}5DE ƚR/: bD|cm,GK*z'!C{]LJTAve2 B`cM+8(p#ƻgGƉabC7^;sXMM Q ^ jN`~_܄&@3?8ifkĉAEC5u5k!w'F팠~>g(Dnj KZx(C<I,^ Z(IR)(z^~ j]X ? 振~ B|$\, %viT.В&>~oRϡ$KMe]ʦ}P-gM+~3}yCb߭Ad ?~ڲ-~4Ⴎ9@N8kٹWSsb"zMW| }i>{4mkmSqi[_/ʹtI";|ذ7 976cm3g}ߞ$2Z&nIAȍT %;qıiuZ 4;X&<ިgw2qRm!Aeڴ֔ 7.#O \E=;Rx}1Phv2" Jy ֹn{{$UADĥm/#dx x'eJXc8@5J]6xP9 vQm3S2#sΘe^E8̤ó:"j}+3{L'$aC>0,KQhP@B>;;lQL"Ή 2|_|g K་ J_)K Jy+Rf=rˉ7P?di$+X.AhIP·&~o}T4eڌ7_RKxQ%K6aGG/ȔQ^3 .Q&8#2@m9]g>goQ3ӌmFa7зp70`f2~D,@}FQhQx,Qe)X`RGE7Rc@dm}27;#6,AlafIhR _;{HDL^ʴva7_(%ɠ>}4@NiX]v;rUPӿzO>(1r4WZ6@lm10): ߱)< Rc"(B666ESjnkyw4'ϒH @ӦiϽ!K^VMv/t^n67@Tĥl|{{&ecmf3?9}6s0Fl[jsF(R˅7>,0 Ώ5k5gȮ: ?"Y.?0)}kIA$@u-bCnC >V*P .[6\wM_1w ;'PE<'+->L 1yQ-I4hv*fCƚ(;dr s>a_7 0" 4лh ąѹo Gi(?}U5f ej゚Dwg_溢|  !(2!!|Bgc~NOzs~~Լ %dNM봗m? [FZ<'MZk! FD)נjhe]n _D?(a?so.sN\Z[եગFWkm~"ѝyUҘB+P qM[+BpgcN}'X #Jg|v4,< чu\SE%K>olLq^:0 ňVB @>Ho.[CZh1'wwt&ˮ?_gxzK6scU:p@l#"+ ",朙䔡l3^x&1D"&i-$aCq{0L\*j5ԴU10]XcO<|a )% DSUz6IcbDɲ+ #wdԃ Ļ7F>eZ@Hb  0iI^WHYws~3k/EGV)BDQk۷7qO@^YN8eoMDܐ"۾/\2Ө 2I93'S@ƿ@4|E'eHdRj>R#LdB u߀we7#2{'>{bKɿ~`/eM6ũV[.1A{gnk)>*^x`FWݐw5eM, Xf&nyLUf̂,R Ic-O0UEkPU0%C{!f`$%(հ`m6cSҰ&E\_8* x|7?>jG !k k#U|  #Z[yՕ~v $g/ !"Hxuh@ IP+ >5QU;V7 6 ",?8?cQTF ̀^w'Sş?lZs{Sg^[LXBpPg/~$.&]D@`fGqɘX4%M]BU6j.\A ޹Z~]]c@[lĥ8V])8R3,>>]ICe ~>|"6ͦEr/ 9ɟ| $ = gV);D{gƛ3Dti:|Szzۢ( -ӧO6\+$R1+A\С×]fUQ%+wQd/m7j5'p1.IA J0.}N}5ܔ4v:6dQ#8I|fNJZ+S5R^d͘2c!e a e" "srdv9^r܉ˑ(бU:( H}Gl d.b ()5Ͼwg{ 6l>l=!C9l5TyfTZ[ b:8'?(ij{`2ϾBy=&&tɚ+cw fiU0t!%DFPCjT,qڒeoIM-d uuq1E.e 7jrRuVbM+v;Ïp- &R.)6F R =ԓ[6 _q.SJ @3Ͻ`=ge1̆ye_?YjȀw/}98U-"yj hLj i- |`!s4W|Rs3v|:e ݯ.Y "UE)["Fdmk2jHa3 $[+‚128E3S"64V*(3/̩". 2tWZ$A# E{.X.];?TZFp w֙f͚6?|Ǯ|LPiKem!j$WOנTn::S(fN̓ H|N J\ &Njm$WsD*c^zn:vj 'WUtwcQUkNWj#4@ABZ+~GTLlͱGfJK`@ 暋@mJ}+| E$Fk%S^}m8GXLDvQ}2 .`xP$; \LHJ#䳥BPsIjKyB~J҂2\&pw_w(ڣQrrޱѳtA-@)+Tt\o5G3k6  U򋧝2O'2K:bϫyY{wߑǝd. ϖ+Ān:-P@-\)y׳i ~Cj`I= XYJZ8RyR +j SMR=Wt#]~6 0 Cmu2h@y2ȝyGQ(.=# 3g"b2)nv'CB#Kː@=@OI]Z<zfi`Vu:!\(/TPxWz}tl`~@D\eiK Q([P \zӕW@ E$_%nkt_Á^*j:O,jTHI׾qv[s Jߖ{5,h40vM[e 2(z旿z#0OP/0|P~a 5u@brϭN~6`5#G^oM`֬ ^$V ߨRW6FTI޽ XX:za(n*_vY2ڳ `,䧿mp['^6rxh 0(ǥyAp++͏Rc<'mZ]% HO?a Anrfs ;zs]ﵳwLdS<|wŠjMN;Շ̑JDFBWUo( OIbkUF466ʆ }~]( xL3@A50sԩ3*mi Pw)6Ѓf=j-C1IJa+1#dm  LR#g?;w!hM6~y{T}.8.A`Rށy?O>2LUeInM ŎHU5bk{i^PG9+^WY%a̩S&Xħ@;st^!`P($ )II@ X"B|Θ뮼j9Z:,MU-6Cp>7~u,ówɰ fc} YH$%8زW/^ ZR?t-iHSObu@HެoۯAD$Ic#]`c:α 8I>oYfj{w" x;?'߷/4r %llugxs@9~m69?lkE-hh8 ̶a}xèF>ut;w(V[c,DdâKE^H s]" `;pUu=wf ϐ.MyX."iNG>:8z]| j<e`[rALO) ASBm6I1:P堃?o HQ!DX-UYk(dnpSӁ-#K ]~?TI[ D0˸7o^ YˬY$JzTeV^OO>c?`4y 9 $*^0qϞsJcO[clM4c⧾o]L2.lL^P,*36hQgL g0zd_Ͽ~Ҧ: 6 G7^}=7߀ ,UB/W'5`šr+[0`RxϾ "RoK*&`5f˦ AD.(kUTpY{UU Ղ-}q.*jE CmT5-Kg%H&@=oH?.DEwލ"[T.۸7!ڳˊ/ +.#HYc{;"r!pd:%k D~taHPRj-7ϱ GÆLvWyظ: %A+X~JN|w!ek`]]fq W/ TCk9 /<#8qbQX x߸_WqӍUc@ ŅyvOpl#f\z?f{`.{!b,koU!"η: es&!_qeT*'妼Tk$9MJ}?/jF\&,C>,`i@0"k /x&Mx% O%z .w:JgYquu$@J岱 "/}<Ĭ*=cō:?XA/?P\5\ڧn Ŵ$~ʔσGĽCUZ$c E⯐ 4Dg͘V H7>@aψCNt,F̫n3(sNok]1#@9mm,dRS`@՝wmKg0x$ K]3[GjO`Kvۭ UʊvAmUJbP3Ek>o=!އ,yiK[ʫ"(,p{ [aD>W[ _2o-J6 i[ɪ1fva |d)rrcpyy`Hi`Ѣ4Gn4ggk'M~% l˲^n~R.y? Mݶx'Y}ru\zFC] e=@5Z}.To}Zc,zxǖ^gw#wm-J͹d@?a# ^'RScYf3nHI%. f<=$kH $~Ƞ&^SE^ x60 g>?.<7rT&0aziď3(~vS@i1Z((IYRxR4$&V08Y3x|'|^qw 4Sx$:(xzt PQi W! xGlֈZJ#,@UTC>N ʥزsX&Dy'JD:"\%+ޭJU)(?WX!s`xqc(A"ǮA<[quVL6rAavA._[*m֘w@N;eW^4E EggwS#O:s$gB^\Uj(2ͶP_Г#]94/WUqu[ sl>@t6ZNX}TU52Ui.7M7_}E6ZSm;=M]4`֪|e K$h \ /^}M6]s jn:갃c  "!@ʌg_x^ )Ю).'xR<;|)P"wqwC*A!#_nUV .+xh1c==.x? 6֨(ב LtרQL`l^vEԩ7Z'{]r ,KlKF _61 P/8 ATFmnݎAS,e#! i*z`W}+=:K=kUNE)s]W_iY/ *w}ՏInI'Κk6 DEm\ Ŀo2:$b(߱kzAD"oC-&0c׿e^%zy0f]sճZ[Ҹ㽀D$vioncqNx_gEۗ(lX^kNziVL ~_5@O/ !Q>.eq -ּ(D!ooŶr+KfzwZdZK\8ߝXb9I~ Um;}vpI)>5)E*TU((y{T6eJk;g]WYicD> au]gq7H1t`ӀM`,|#3cev;*Ώ}Tb( 4,u&##0uDiވ7 xJhyѥRܨ Dc5'` ` ~3$`Usӿ "ƛn2D bo!s <ι<((~AA?cTUPP]GS5|[5 ]/}yz=(B#V[n D cK9ݭ%Ȋ*d&b,o_k_97Y3G69 'OQAIO"!̊O?Ħuџ{d h !te"ok7.aF~ x4o#,őe@aSVH@[J\p.dD2+PVYc-JJP_רnM޳ AL<ww7з T*8Of\z6.W;[ n˜ʯv3p!G}"W~!v ~O+?Rn5X1pУ?X62fF[C˨$YͷAzf޻j-P]-Yvyj=q:lrQt~UR4*u4ѯ_ kbQǪaA3]w߃˽աOԏ'B&z'.YLzd,G?>S .z3*N}Qw\ŕ6SUv('@H r&818ᜳwכwwk{98M2c9#@"LUuGIF%F=7ԭ[76Ģt.kJ׿K* '3O`lX,v@D(o.-m~A"@>j#7 ͎Fŝ!jeQ(gL693[^x-WJII I6F}; 0_" &y; '+ISQ9ę3f='hOJZX˸חX5SR'=яNJSxߺ|pH8)@y}kYEU%jAw,X$!2PZ-o=h3[c@gku~ E`ukbÆukUЃs/᪁e[X9}/"DN}ū@,CY8+7kKXåkt==eW\p~&ʸzzϨdBplt5&mNXI<Ro`#-HVV{A*ew4n6¶2>>!.Bh,=?9'"Hs">D7AD7}w]N8bPcšB)2fe`o.m,?hzX'3gw$@""(r=e[[ T`q.E1$95]m]N3&|dT2huʗYSN{#oh|7 Hܒ]qtzWamtP6BcF };6`R%!6p?0: 18젣:0 snag%5EQ8 {_xku"hle`F(%iY>agv:iL!UpDp]22r5J3d1^ ʾ _ڷx((uCCbGD=u-m"l݄I{5݂X(wt3bрD f&&Q 细 JŀAMreJolĎ-DܯΙ?:$ywrbCEX'`(8%zkel4ˠ?Lh4C4_JGHY7GD;aE ђqlO=U}2^-w (0W\PExGaݳ{̘ S(}r~*~m곭'<~, 3A D6˔&v&Nz\^{6BwbuD+QJAJ|+_N;:Z l޻BP@ƾύTFl›M>ig1ּ֖ik ,[o) /1ɡ>'𾷧')Jg)glqީgN\N:,nHD? x~ 0-?wD-ʚtH7SO.:'#eGyū^ %4g+A1i%"v] x{3v]^IF[H&<`sB#fϤgܓy׌D#2l> 1yN<ݦhV%rlTYh1QEzΛ;/ʢY!^+64D(ǜ>|ܮݿ`3n={\f̡OD~j7mQZrmgLQeg%tP2U\&km4"eֹӦqM* _sUJ6Cb4ZkٹVk#D`jN OzQK՘h(݊0Bn[Df$v(atsFrJ7Nl60PPP* *?8T I?Ac;F'"/14>MbAWԞ3ݕ}WVOzQ,`KAz{_oÓV<'e/yɋ$zQ ;=(K|jPl( 4h>3a 0D;hr]*/]@$Jmn5HSZA$|}?Rn1Dgb]g PK[F@Y[DvB{mcB%< CY ʈcz&RCJivN`: Æ|ζgrHe3]_|Ԏ3)}_~裆h鎕FABEI:DZs7#Ro] ˀگb@ ,Î{YOnjKGzUr`f|Gj8&Y룏9*!mGZe_vZ#T& uOS.,U&l=D^DV0فN-̇0nG{T^,C]tt#Qk/#멺N D6a^ܮ a5ї?я[@AJ#YB|fFvk=O2YR :O});o5pI2N#2?5!TE\ 0$ʃYDw򄗝rr=sա҉B1c,0\}51~OP^Km6˕?e#䗿m8qi _7*NⅎYT_׬ˀq(=WBU|y{+YebA}V 5g߰"RHTb_z|%➞GL2PXЙǂ_xͫ5yTof~֮]>s: yU>қdN؏a߿aG<2g(̛vef5K=_]bM̆| D" ]}ݍF0>;0V$կrݛUunI+%1F=AL[xbC;9FĪYJ"JxR5HI8e|,%JG|֚oMZUiߝ3-sV#gM)p}vɣ=TՀ^߰v]hFh4(NƚTX2/r=<_Ur*],`ymT|`úufӗ>(R .L0DR/!eB {z꿼"f$ZZی y.x`typBaEEqםwqA1FC(V/\߹;U;U}  >yw?{k׬GU?Py'@*i}wu'$oerdS'"y~7?x'w{w1>:#@:hzWPڏ{.;Khm7 l=Ġ"4B94+ ݷJPT8tۖ_*ۢ`D)QzԵJ ~I,J2p["K= ʫΑ*ރa6SV*,&3{KO|+O{щGY @Rd (O<%5*/[ fJ;:R&Z3mreA 2eK_ǟ [G$8Ѝl0]lwvYFջJ6FUAOO 4~768`^{Ujfj^c8;]Xo\qmw-]: E@BDR4޲Csڎ_@-/59֠[TSN>GekI۹o:`ʹ‡9c9p㊱{;˺绣?00J/&R-5U Rm3 ̪ y@V*?ƺ Z1 8*@,~I^*^xT$&$F >17wr(bΰde6T ^/ZOC|QTe5jͪD>pkpvl{] h) nBbJMnݺ+V }}MT|A~y/3@ !8#wO?~=wޚ!% (Y;xF˗j(G{^dI ` QX| p%چ.t]lW(λYt2ѧ &XsꩧjV$ 1FŴ~FDl ; KEXZovp[z yA}<5/yО`>AI#EX~fO$F@ 5|ۚ}Q CQ!"˅@ $gag]Ԩ$;8 &xlf Rg_RaEQ}{.:h=cCުI X7=빅w,~ϗ_r1Z, HԻu&V7K4 !Oj(|yiEH[mQkmk<Z6+EAwu "zO bmePfiSEG"'IڬBG?!6Qϫ<*Nyɉ_??ȃ7]s?*ĔT p6 jEa+V6PPۥO-P {9ΘF_b6{dlQ2Ƽܷ.F~'#R5J]x;ݿtם0ի@I՗ xD(2_$1a!ן}'_{CfI}۶dez4C':Y 2eԶ\VAU"㷾x_l]iW®^ppמ_ƛo Q[*Qm;,ˀľD"ސ{ם/i4s|OԖ;֘D3pBPΚNaGz/܂.>w9nRIO`1("Ta!xtϝt~q fDsKGZBrw}(Uf1LnVUG)SIe(<1-|j6,QyQ~+m1Hb'w'MPQ lGkׯ__@:eJZ^RS֓ חνGSߺUƁLg Eh& s=oz聛<_gaQE$٬ީ'0H[`30'Q~wsel m!-U^@@qT*m5kq>.! ]t1Vt v} Ȗgܘs&Dy6 @" ){>?YCuݧO'gQ=(=8^7~;Ï׿}Ͻa6 !?̙A R>|1`lы1bwq7sqb{3eT"TUk5 +pǧM5v+1"i ZME9%oxofĉj9KD۵- bsϗ{ڄ-xʌpBc]?DFj颋xaXB?Iy}|i#Y6cZskcm̛o?%Gъ.WvVѠ j4Gԑc1* fʰv^!#= cb4BsB𭕇[nv|g}Wp Ny+ky􉟜w^|Z;#7>d"QdAd"' :GP)/9[ Fe% g֯YV:Pr'hQ%Kyv-ƹ.vbt.i٥~X"S.4z>:Xa6lzVa8j`R98+BZ5r3ʬl$5NT q(+TR`a9[:|P(*X 5 VU?k^s[T 0HEQ`pԼ}!#R_(f͎LEn`*"ϼFhG)CXreTH?>(Å Nu38V"=^^Duo\ \yՂ, /|}av ֨Rz 駒kc_|~l(8DEls['qG|7C-o QѶ|2Vԡ3'Ж`ObT41MjJτI3D(㡁-D_*!~u1>1P*S_U\'Df 9b!Ǧ@Dsyt#m qȝkca"[}ΜPHyCTqQf߃C̻\q Kz!Ͽo~?g6P R[ >ԳPHTe5g֬\lҰPU/!,O:?;uynؤ!Mbp:U=={ix}6=V%;o݊>2k ؔ܎:a' "}/SbDrGL {~n|GJ_ʀ D; |fO~EU/T!3'`7[F}GQ OFguٲl@//*#w1NΤ.ZQ  /U alEkD#bkl Eb>8dfX4hk](@V͉v2e 0H:~p9{MGiczxNz$ Ě3?/wu3wz1*+$B"Y_֛4#n8 ocꡌs>w!(ST\~e>P#o? {5M@fd3gG{6]8q{ l%._"JcB@˒ (/CB@6f0kJy?uW\Ǵ`AԙW'רU)5V#y[0f)IakׯpoqMu񇋮6Șj -s ~YkK Mf΁Y5{eBUjd /{?ox=ۗe6l>L1㥧o{[}d(l;\@Zk͟~uD>:SD<}{O=SNy5.͵/Y7zU ( oCDd eY'Vc"V)BgPHey11(B);B_x"Z`E\j}9vUw[{cj1&2@B"Rx織jE\} HbONO~b^:<>BB%k{U=@dV!RPOUٙX]Р6̦(SvG)Apa+sQyS~xTNJW!@vCs@c.˜sL x^[_7P XkՃcJd"F7HylA+*1eBGZ9g Lz>c=((.]~@mW.a"1TFD3:[oafQ ~ #4NU9显 \-8O.Q` A̜9ly~7]rɥ֭v0;ѹw0&M#ʼnUW\@aa6ZQQcR;qD^}9?K 1&T?;*BI~=HÆk/`!E̎wdSҙ1н3ݒ(fw֬kIC`_2d];zu/p ܲٺ[mʉ ֚֝,.\$vkM?${ʩx{f96.Ȣ 0Z!w;8Jؤι;y:1:c'NxfOHU>EZl*nf?~a@"TA' ZC1N|ы| RTPbK-_~C(6Og֬Yz{E9+Kz!"!ko9Twrb2Ƽe/ہ +}iq`af1/#$U-q{~|ehkk'{S7 Bt˺6uP\ՙ5lq\QmPK|Wd%wzp:]lsk#7Kl: qf>9X~흟1j6r gN9|`!z!FQ/~[?Ad0>6bTDOZZMm'ZO׿@GHT3$a…f>L%K?s<íwk/RX+!nNy،3 DoV1E~__{ݵO</\gs'XyZ{ԑGpe˖ŠV.T w\/Meè )|LEWUD|kT 1H"زu*F|iY(DdFU0eGy䱩S=og$NŎ3Wa{gIHT2j՚ɽJFDe!¦X:Kt3zAGW AI,Xͯ}鷗_fC JU,BD>hCY&"#h/EݻEkas+̙3>@[:Qkk?֚z |CQEG{/}I3&&VLB}o!#q;J**r/zΦ.`ag"Dh@U"DT6ӗPc݄׼JƌC>~BgB&2ʆLB:g;Z PI&`ruTjB*8&MtE "q=af8Gߝ;26bhbRn+'7b! D,x X߷AT]M[()Ijb0ֱXՊtKAn(ZfA`^A6Ӓ /;77O7GJHzj~v^fVqE,D1B~m߃Oy4'rCZq&V!q'=1C~;֡_b_Qv:Xjdu:/HsxD_|>n^#&3`RL cLtx[<Q)\vwԳX#@+! 8(R ~aGXY6/;Ҿt3]lTQwbьaeF^W2Ǝ*:m "> {,H6*?j8BR\*KѰpA0E1uYs,{za(_S9;ᄋ(?mliZ5,~nPM*e*"`3ʗL׍1痲1]w(^g͚*0cT5`7'ґX0lN w΀RafPI%۟|ټ,v̔Kl'jޏ;cLh?8~Ti#_di>ijfH|Ξ\.Eskrhd? !v]l @Kk׺|yEM6UDFkU5 h V.90;qGc].@ ҤE0z0!DW{?&|?GHo{b6Gc'dPDؑ g?|}2^z|WZ6~uTkɆxXEA:oqX=\ȼYf6\ Ï>C"LTzz{> ,s.Kb3-6UhnhXcT&iXUA lٱ}D$BdeY&1Xm-o{OV E){Кb%cs뭷ZkG !굹{f0.g'SOT+-OyL+ xv e@;} !c>N8ooWn* Ԁxĝw\!VO͍Ŧ {/ "͢8h#A7KI`Db#Ku.EbU%[UecRa͚5wy^{Wu=O=T_hoM9۴~͑ody6p9v`t%ƅh)HV"KQ-\~mѡ˫]bUU5Bm"E'#BLdݹ1ۮVb@  f[/zTA8#blXX7֮q/1TJAt!UsԦQB9#-x_b%|◞cŊUUH Ѓ;v.&bV2\{k?@n %z-+7x1l42E=&N sfïo/g_]&X--UћiuC)7tKӇ+=آ_[(Q^җ4Z6mڼy Z"7e%;Eh̳7ax_ȝ|Ū%Z#Qɘ.(<tGK:cZ<ϳ,4?f3~@3vgKYek׬eX6B*겪 JU񘤦o )qX|:D;&O~Yf曓I#tPŨ}y}U H}<>gzի_6{DlJ@p!^B,x'|,Kg]믷ֶIZ /ZIhD"H⁇tѐ]fc1p}@1Y-W0K Aw(jۚikGyRr|H(UӦ'@L~l yW`oº1WcJ, #z!8W`p7<ȃ 4:e,3f`khSzfEv낵h:cbѸu%nł>Y)%CSNyED&N|Jr5^ӟ@,3 ֫?}~0P6{l!YmEj @nC\~z"D4ek~_ST_*ᛑRܪmdn QQ"BX# fe,s""tFSTrҤIuTSzꃟ>e"i!m!F*ʷWE sUhQhPU,ʲWoJz1eYdЁB 4 \U.աfAAC-:mx[l;䨬[K.9CYuy}1+Cd$c,++$"j5|kFi*9vfH">u± g0!j(ֶ 9w:{\ |{VŠʦ;uoy`j_ӭk "Jp~v©1TE%9A@L,/ƚUh4M+؈E#|'xs8˺̞ǎv. Z[}3vtu2n*Mt2cYa̪ y?5a`f@5 @LZeEU' zA𭞓#_ &F14 6,pj$`Zի У 8vi'Μ9s媕o[ KQQNƘYKh1*h< s(J6Y4"XcDDUEDUD'/5XɌ! ZcE" XD\_qTLEADBvjj*`LUV"Uo|p@Q1g[O]K6lOy47qȭdFFa8B0Xʹ7P5ԑRafU==e'ĕ.ow?5g*kÙA%`:L;%M+V;&E>sea2h('~iv8* Yw 7^z DZp$"w5׿VxgjwtM`Uࢋm뽑U| #bHE21:6*#B:IS2E; cPMT$b Q˗/2svPRqDPG&U ɧ|}-֬۰GfL۬ H y (MҎ"*u0}B>nGSGqT(= iXd !`mjS·_Ǖ)ᆛn)8w%_dY'?ɓ's1+-yfMQ A:YU΁8# !QME*V~:-"8Z"* (B,y#1HU UEeD6! z{zˢ(BfSUAEdU~cV5i`Rk7]?gk!fodj(XXK2߿A-|4kFM{^S|s a}_/y衇lO-}gpy7-%->{F椘&7=Fr9 />U.vit. fe .(y tM31HtPVG^|=nI$,W?y~Ѣ:Q@zN[c&LbپmYh1$2zm7RTq+.]bi|/#MTR̩9spqwE>t˭_Kovk#D!Jh ;gf4eYr;" :PwϽϙ gYS->ࠧԘ+:(rWm{]u!Fy]ܸrYyed#YT=^e$o#2 x&J8Faszz%(0L @/UF"*U+_VD.+^ ĠlMJl"3v +WD 1l #53-Kewd{@Dlk9|]!l$F6FDY+1JlmcMJLPщ'q/:w'1L@ђCCe2bimXmPAeV.Zh1FQ5l˲YjhȰs.~q63ǶiybC[7Pp"-T5B}Ǐ\uwzұ. ]m&K/®UL))82)xOfGKB#YF*o+ٞz%W!w5x(7u =3h< iW;l8-4dD%F9m[QdhmkP 2E}O-~4uD&U2e.˞[lɲ"e̙3g̜SseDYmA[Y#6mL5N{٧P'GK()ϡ!pŷU6(lBH|ѡ:z٩DemˎDoI%%V |z|\uej[)<@`تпR{yHė+f5yY9I񎹧Ms! !׋F:HcѐV`c%Z-=}^sf;p}{NONec! `ZÝ+C4t8e1੧YrS>Ӻ@l8ՒyJƘ<[?>o-xҰlO%4rG7Kl!WzQupJO@ )Z:3%^wޯ.zI2XI] ]m!|7jFԃ:,ȨV< i n3Q%~H<"tÚG֮X2Ƹ H$^DacDT}@"O8&(oz1N>]Dx,~DPI)lufܹ(Q[PYZQ [Xϗ%jTG?Y~o)ll C K\j͘9cƌİi+ #U05޶)[?!}LoKpK(lٲ;;XlEb !,tfk V `G ['' +xN9<F;CmQtnh[4pQd<葇]::]?Xq^Ǡx2* mDڝf[}7>kJd[*= r~3U ,Uf!xЃ"œbdo f5y?z}+66νo|^7eTeB&VU _~C9mʹR~@X'Z8Xoy{g; 0sԆ 5$ 4S.^)kmOOɓN:y* 0@| R$GcHcHl@A01Ĺ󬷟sB Qqw}_~>KDO,\yl4t︷I|oDJwPْԾT8e%3B^ahnؑV׷}DéD[*%P.'g=ow&+JeLgrvl"*3D$YAm'Ά5: SO=t5kV/[Y2D B2meUwϽ[K!2 0 1XU+/>{6ZM^b%(ISPo|S)_pg6><.@Є!Ɗu,FN8meA*b74j^Y@o~)T@d6%)x6}inBrε`X6gh[bU]7߀PB#I0\&{ݛGC G4E:GNS?jЛ*[Yվo~ї⥗]^hz *>\"LDI@UsEH> U1ε Zen+p):vZ7&G ի}{~W_ue]kg+K*!Dkrb$?DklAbto6LL"i[ƪ:-tfS#b I#c4뿵3MOš۾Cθ$ W ˭@գo;vŐEQ׳,j5g_v)tvg>g̉!`Bl59910DC\O:+WзaK,зe1HX(51a g.+D<: ufpb7vHY!ۉPklfye4 ΍vh$lk,k,}̴t1tM;6H |",Q)x/{R%,Z-T9Ƙ7W-[~'h1B;mke%9w+ !TrlkV.YPGD=S:,eVy2tZ':0u괯z'dYb!ՙXQ6lx;ߥZP# C-ʔ873iQ7 c:dT5=L@BV2eʌ3NgDb#ax }bg4+QG{ꩧ7k9WpO.zI11ޢ(T4JJD 0Sq;282joZf(P> J̄M`zPc`md0Z"Bms3fs=w6u_rq{Ι3{ٳgF9v`[{ 3ϱD[1:c>eaC+/_o4 :Jmn~bjlHJN|P&W\q^@M D.N XO{Ёqsj`KG?>&%V IB~KV<6$޻|+u&Нp` ,[{57*,MLy0e p0r7,硊e<c@|{#;VJg6\3"—IS,'5K򺏁B!X{-xx>ԃ~4uZV yg$ ^E~ORU#ݵUE*,`(6^|}CZ򖷼͜9s`0TBR>X%ĘD'Zfy~hۥbUPB6m01*I]5$!u$5+X׷AU˲s={,["g]qe[fPo:ƀaqt]$mM4pqrvdikfysgm̙S+W>pk|TzK1M>ðSQ(Ke&y?6vf݆իW[n֭#Q$75SL T)ʠZK܃<,wFe59%UƄ>0 ރ䙻oI~@ư>p~BK--Y\foB?;%&&Нppdp͗mΙwp>m3'q!"7hJ )QB,w}9y1lΌؤ/KtK,{WZUEVM2e5kV)}c(RD.WIZD10 UO6N*OoYb Tj2!Ѥe1 S[$ژ b DIDZ{cm~ MTqsb ^| `QGADb;w&N0ǚ*S}}[2dnuNCR%!kzc!bSͽ؊s"$Zlي+-[lDzu$Xi3h+a+  f+ꪥKC52E*Hu th2l֚3^j*)ӈܿfͯc k&~ +_q0 ~1c. PȤĻ7x|g"2sC֡67"err-Ɓ6 QaYlɒ瞝4,as;ʉ"lC H95f'?!$Z8N UriļY Bd(r_zoo~mJդJ-nSju _B2ku裏e&D>Dk 7s+Qf֬YӧOsY7 UʲXz .QC z+^~IU-Jշl4[ャ?E/z&Y_~_zcItIsNWEp!VYUNk8W8 UUAoQQ1Ʋ4U4ȱ,p-a!B0Z(3.|r“O>eYOn@ 8[̪"bȲlC\M \.d(ׯ_^p 1n۟j i'Tye#qvxKTOEȶt w}KW)%NNn`zH ׯ~߿D(0מ5gy^W'cBc}х?CrFZseXB k^}9zooOoi(YRUSFqy5.ʥK8DǚРLG_(cƄSu>xL̙3;-K&*gV>P2Y{\ 6.nC s%Vqֱ,U#ʒ뵩Syf:5}Æ uf3qcPєj0i&խsfsYǬY0`;j+!+#b}Tg(*TA$]JPôZcB$2_ya1:8"^x7`QXBg6;BL'><Acfsu> D Fe<1ŦYwnv?Qa)߷lXnu1} [kXlX:*kMYXixeb{}'լ^+OF[O^Cg6( )HY9섓O&cuVV•fsO,gcmǃs׾xvu6% <jmcaQ0؂ͲԀ Adq? ZYj,wkҔ-"VhvGV 3C2n #Fq cu=/~K j!o:l8ŗfϛo}Գmo{˺ի}BCw :zcmnCYu ;+")Q5F"{9ܹs]JT`@Q$xocYn,kYb3gvM5M8}kV6%@]l@㲻nZBq HOc{5ޗ&z{`W /W~ԉ'\46a"! # n7_bs\%_Of'G0I5Mb1bY<7l`a&& 抝2 h {>KGD Kwso.!Ӧ>6cƴYfYgB6PcE!C\ YBrTuV"6&20m=cҤIYu{( T+;=tm %f0V )D!ϣc4Wan|hNO!TeYfY NUc qK/k_^Uׯ_;a/f( c 5Xh2h H_4s\MDDSm9k4G -]lذ5⅌1lq+rEGi, .ܓ 8v?FǬ=v_[f޻6=^Z6?t.!U6+_VT[:ēN {&6Wp(>`Sl[6) ` TE-~zix)Sˉ"Ra }O,|[׾>ٶHŋgUe⼧>C#4iSz)FYfN%PG.t61FrF@f.XJV#f^񽽽gu}sgs1{< Y줂Qۇj>HZ$+,xraIJb cxٽIj=,\9e &Q%#ac#H &-`0h} lAI$P:z )YzM޳zI&=cӧO۰( ,fYf3JDɬc*BᲬl:g}$RQDqZYkbfbj56lz| h aÆ/negX*Q`*B^6~Phse!QaL}og[.=RP /oymvؕbBb.7v'Q?cYmW'R)5a K_IU+/?lʴ3gB*zXA_{? [ZCD7DfE uo,0eT8SKT˲ӧv~>F1.~ ϧigfz6s, BY3al^Qqc_!"g93V^ui<:;cFZeIeW`RTi^T k*'|p˭ŗe^M2u y{aڏ>sl6f}3Ő7YeHjVB"|b_+7 0އN! y[cذ &E,,˼ׯ0qRϊ1Zgb;`L)'UJ^;2閘 v+V.˲ b *b>Js∊@[vӦm [b7 W.s"ȣ,#k|,A%]br "bkHk8ƤKח^v=Ͼ&\{Yw Rf(﫤KȪk*0"i2sW2U8tС/o| ֺމ#}-HHBx?dߨ["skm;t.)3[k2q)<2 ƚ zījB4<|Vq.l ( AT ٧n_]݊C׽u` !R+⨄__rɯ9SO9ebGEPEkE4zdb Bd[^jGeQttF($%C6֯Yvo` cZ}{)ST Q$JeEQIU+{h"oHS)vZ;|R}"c Ro,QFɓSSauֹJ?6l 1sVk;gZ,1FYzuׇےrjFK/T4Y.O8"jr0SJJXØx`q5(j=ʲ95F0쬋JW9vlM1&b[{oW^u}ޛNsKWrdȩ *9pZտCۃo J"(\IqDIUX4B[ny޾(ATfϝ죏n#X 9A 7q.E]^斡2(Fdoߟ]~uџ幗rTfQJ>jū:rOҁEEeFeq6+z'NuBC`%s]20%Fb.k'g|jnVn0roڶ@Ob:n@{}Z]r3ppR5kT% ToLO&&Qh6<X(!Hd%ISS0R'\ beE|Y;PDln(z}ڔI/yK'OuE4=nōGExacw&Xom=>l ھr u鿼އTul9HJa(Pv9>@Hs":;kЍ(FV1L{Ι3GYviӎ9y{+bĉӦOka 3eY YfSޒI XU"UCBX*y.^x󐴃GHb$eQU#`aTT{OLa AUSS !&8[eYA >5i$mkfٲeDT҇bŊWPکP!i,w(ikmyD ΐ3lA$W]sE\t(*lsnZO6c;ْ1{p U]Q/~տr)XQcUYBYٸ[olϪ?4mgCB1V&WxYG襣_Ƴy"[_ܹs9zG@H?hAU?. yO_^7e"2O<𛯿"(n~}~q+RlB D"RdGpbٮ[aժ?u6<؂U$r㲬^ϲwBoey;gNb29裧N:gΜիW&:g,Yk2̺cz=Ybdi$T8TmU?(?)eŒL Z$4P\c|ݺu?裊_|mY7p Ͳ^Ѽ^S ]vҤIfӗeQM_fYv;($AC^bHNeh`Fάy}Sc)=33kT  DB* IjŲ,ˊŌ(aeݪ #D7t>.6?ܧ{ܸ2ݷ{wĊg/U 9] 0 ˿z{V̵?L/xjO}#vİgǂdW 51]V*)TD p\{Zßs}dY59$hS~ m gSl/JyVM wdez@W,o sC0 s 1%=Gj<{{{kZĨl_R;pHciRT[̼ۢa1w4VsHG h6WX̡ ʲs&$}2԰xq-5rN`(YMfr6E(^@MOOڐJβf͚fi0+V4pSS ;է~z-z| ^@2ꏿ;z-ŰST*"r1ϭX89pP;IGQmC%@1Y&弧܀}8jZ{sI&=E("(6D}>Eg{֟&JoJ'$H! !̽qνsd s/9}{RkVQU ~ 6^4 SXgìD7 3 >g޼E t|z~2os~Wr>{̙3OaÆ490ߺͰVռn6騦w)\p9|a񆢄c"GPu۞Jw=<Ђ41$XΘ=gܑ⋯<ϳ}0؇3lTiq>mwIhF=Yw%p)La .G6Bn”1Wy "ލkbo6Vԩ60IDATcd|~ˣ6 %=2ц 7\2cm 3|ocKl<U:=æ譌)UN$ 4| Ls簔NEy2~܏|#ч QӅΒ!VYWղ,XttV;scy^@U҂Ex /K"**EP1]ܢ{[ 2~@\ڷYá2jKl<n/k9nՐDgN4z^R]k+@PV+<zbIUB˹[,h}E.'2lxvsˍCRsy'I$ >6${%E BB>A LOw$.B%[ų &Onw; @5Zr :::uN5 esKyߛgy02 XhDܨ|m1I^{K\_t7$gCbt4yZ[м8 eJ´n27({c]I\hc VSwj`ڔH[HZצ!mqL?>fibo %3aD$Pu.!jd1FDTXZ9~K/_w}衇^z-\0eʤ72FUY5!Ffn%e)Ÿg[Yz of~}[$P ja1綵|8kr_jgj;) ~\x)\2ض(N|{DŽ V0C 0al?omod ^hc VS69i(P,)͹boboiR|` Ķv%vscd$ sL-*&! m,g =~""lMQ!l,c6e~ Ȩ1U =JE L\wu<pɥXbƍ71 i~07hS*.ww1;LH,<8;vdlm`eFN$cأՏ$vn D;bQxB%-a^71G=ܣbI@Aey1AdB$ -(2%JeEӘ+(XAO)4mlu6`,[zI'Y`3cǩ >o\5وd̆ Fuռ>9#;n׼gGnɣ&Ol@ H)?UU똙%DkLT1Dgl+:D*DE Dyr1CP* E*!0̑xUJ]gu"1X6PT !ه_]MTi!MnqsziMe12T:'L$L?WQIDa*~3,x Љ' >*!jrg+jAiΨn͂#!(L1hgfxJ) ay4 }! @y,I/Džy*cĀc]fmi¤i{x-$=?c]YŢdA2V_}(5-KР%roosW%K#c6*ζy}8KI? Yf-_ܦD%b0 կk 0Ww6B۰scI>P(֘4u&OtI:c;*:1̔Z_-XZ*sU.1ʦLȥ!V}Hy[kcLdUzo5:rʇzh5$I|RA1-ChtM0Mh4uBh`XIeD#xn%J$<*9ykOⴙ;[1c>%J`S!ɓ1%>uULo<$o, d9fA4}`ͻܡCx􏃶mq'T 6WD-7nb#=aUk~ H1VD,q^J|'lȲ -3RPYnuֵ^v͚(}d!Ɩo}(N΀_zǣ Ȅ>QZ}T瀑rIR:ԫURᴏ~9\O'I(sZ=7_׃DLĢ~b< '`ˎy;ТLII`U!!u6fs:{LHD_yжFHT{L}$z~GNtQ:so73z^t Y]}sgeZAht)voK< ,jƬU)v3Cgb( =vKȐrx7oذٴgl @hEH cD]v8,ˊ[l4 _ئam WդE(5Xӷ:mڴ]vyڴi{O5Mcb` %8ʆ"*V>cȨdBUMK!~Ez}o|Tj請+k*}f+jv ŗ B8IT-C ;F>p∘4 }?CRgEDY}t_M@ĤLdl!{ DjcЁ0y A2uk@ex9|!F䯿5| o؇Pؐ^KqT4W'v|J(x\"}k32JCDb(o+J hs\rwNG>_`6֚ܗF;V@V>8}}_LeY1nO5k]' Ala2M&::7&#Uc~Æ އ^~+W_~Æu*1H)LUL}8 Mbʖ $ i #E%K.Y2g"r.I\2u<SՎ{{啗pι o7mZ B.ڂXВ<">H^L+ᖰh-Aѷ3ƒj[ֲELlM1&~ cx ( O^{XJPVc&8 UHOyXR0U]`$O9-g[ tK?Qkd Sm-5\"nn?"=k}ǜ"2drk݉KW\_(J .mG@[΀~{/yK_҃h e=cڵ5Y!XQG 5-""2o 82.K&uL0k%ZLz羧oEGZ ̮}})C&NYtNEA@PQ[ ӻEe[A _'OHdyvyn[NoƆd(oSĪgm^ \y>܂ҚLK~@+l.SEgGGyaD`Y"<}γ0K= ' |\UeK6EH= L}NX<ީ]+Lb"L2iʔ=}C(W!Vy`\fYF ZD/\4c̆TA"JhTe6lTHv~{56lD⛏}}r !ĠqZgWYSVk {ú,psJsu<LM;8u4w1g5Y=U _j R $0[Kk A m@&К괛3_Z֡tlZV$׾T\b )s!q8W?OIe n98tE jQ7Bx^t sQoO`UL%]zmI[xj1jCiҳd]a@8zbَQbpw}&~LT,HY9Csmc@6_DsX'L5GseuV5f@bӷᚫYz턉,Z8c W"D0$1( XT 1ĩM0JD QE &2D^UuIbӴAa@ab}t;yn Sj&ut vmW,YƖ>|g˗PfK,э7kUEE5E`^b1ZcB$q"}OwF|_{lHbՒ̚,hP?;Ӹ48RCkўY'vī_W:֘Ziǘ?K/ [!H]$>86lTY 2Ήl c,$go"sOJg3ޜN ({uh?x1ju{}DB /}F cMBnS91/^ʠ>~ mm re{)D* qxͫ{Cg~K={߄ >%q2?~ۯX kKz{z]LW (rJZoτcÏ8+xKZܿЃ-DO4^zڵko_ƽ?Cb2%i3;XZBLLP1Lq>̹9G~AE(3g̬<&/ibD$UeGP.lqeh8Xkͣ˗=wF   Hǖr }B߿`BH4a|NV60ͻ@Z雷:o3*Lj!'i QdԦIkl% c-m1nL ߣ+]~KlB=}'P)'1vhe"Kx"0CιsQr[ͣ0ضyp [S)MX 2}QM]B,xh lFe0;akFN^w 6s.ßxy! D 1? 51j!ȥϲRz55;4Oiơ,F0O~˿_AF%aYkLDX֘XH Me:z}->a̝' _f. @+@b:7A2Ee⪕1֬Yvݥ]x?l\*11Goa c%&Ib^OeD1'tsutF"X+cկ~uؑދ-YZ8|֮YM7<kd 1ګ=4hV,CV4C.~\g- *lm`%ت7&ZdU,l$s/srUB*&H&<9sٵK $Xjɏaw:;Z!2J<*gik1BPJ'Ny;AbЌm|sG$ e3}ضvp[7;Fѯ<uB hni׍p _s(VtV+WשGŀB4[3(k.dD""Dz*zĿ3Μ{ϽP P@e!P9m`(+2!:dsWt{qj)$=z=ZaC12ޚ_hɺ N>MƐrڰ" 26u(|pZ&6lg8/K^tHgRFw)ڷc)׽IZbg$ "Y _z5k$G tsLUI9_qQT48<w ~srW'i`6ZTD6Db !;&(bUUE#9sĮHʤ`2ŰƄګ\zoQgnOMt{KCGU!{/@ :hd؈|[_~{߮c4 lh;cmB?:ϧIJ+,H{jK4G1a*lu+V ?O/*XCX "#1%cXNwiZ-6L?؆˿4VF#~qYz= KՊ g ԓ-k c^|鋻4)y*0({=>dY[WC(G[tt~y^O>MS)T)91CJA"sy'm7yN>#UXRU@r_>yF0\T fk&$Ijs4AJ`Xxc_WfdM+3g+DD28ԲFIzA?hv14cNJ_Ao.>Zkh$@LD75g'2CT6Jz{qT7N6=*B̤0#]1CFg81p*o~{&2F;nY``_`H(0Vj 7GWoqfhdz7[(_BoVʸ3՛gݙ>bX R{Wz3}9WD gq>:lTn X/ AL}<:b"R`x`fwao;NaVZ ]a6T8so9ɓ%:*$D}gyVx,˃tB}e!ET5IR W^{MZhET*4[5Iz 16mکVq΄諕$jiz죅4B"="W]y xٓpWXUO-Kjd>W[`  Q#CR"L2y=u睞~ӦLVՎZO u>#.L Y!vX˯-k&/f$W7]b6f99m j邊(oxte{m`hEazP*4%+y 31vh[m&7/s$D} }1Y1Fc;^L<.M\ 5 x4L%c'?ؔm=zK\#Pv ZkGj?>a)|byyؼ;ODHx$M4J- иSҼ&:]J]|{B4c{6xLzz7rB^/<ϋFLc*RQ)Bkƍ)UC* l"L =rϿiuÆ %` G2aozC׸_euK4|bD*"l7U`T0֙TT  =sfY~ŗ\\ \e*~߻Ap2qʫ_JG**P3}Ɛ EA" bcc lgX C.{?KCϒ!bYHKܯj-9Ι9Ѡlh;mv>/|V~?xM4ƪ,?v5?v]{덭&W{^2~\*MY1$ҊKvuh'jL) > ܸeܲwr綾_ 7wa4']W 01J5>r'NjTUJ(:E`%;׈ *Z J_vQiNL==6l?S]wu'LXT*UCtԑ6$)M52@K6L .b-vbŊ%K,l}ass|+:aIJ.[?.kήݯ{Ɖ'裻f8ClTqwr!fkTXQ}^?wͺ ]G恱 J&vʃQ76M Bzobըc4VC 9|K5MtqVWz=Na)@Sz~mOnMѠlh;m%@dܥW^#ME 6 1n?le'Au W?鿯Ѹ4BH+Ws?BH{o.a%k4-M4&Yd{wv~SN]vmR%RPP03ҹ(b\aFY,$.!QaFc6inAvH7r!/cCK\iVYp¥KfY֏R O 3se $8.Je٪5kk96?~\T;;;I]c$T6/E[^oiI?Qڊ+.vAkVՠlT CYTmqÙc;2*ZH'7I6 Uw!/_7 eSWNxBm6Z0:Y6h#:*F`m EC_6װ74kժ56H=n2.19r вre-RƘOZRk~:CVPdԱ7x!HEٙ92 Cgڒ147n _psiMQ ɚD iZCKEΊFĪj# BIE,SQ$ju"s yIC,c)OhkY*iѢ%NMk3M ۨa Xk<0ߡu3Bp! n\1BI&*ɀ! sn >?K^V;Db1bcNT<'b\6@ UbUH5M|rۮ_4mi)eC\@*{.{~ׯ_jm`hQͭEX&D&dy39PAr/,4!U+VW jWE)IDD"4B`lTM$TPrqh3P p2!};?{"Y2<}m`mY]:%3PBa10&ˮ$|t(I xu1k׬o}b̜u|Hqؘ,˪(E b,*"$!cs_tr{6dGaC~y'` }n+;$a-ȓDhpK|Q5ws>xgPiJ"D!+`YHLQ4H^Kk'<Q:Vfsϙ=U7B.egX@ hl M޻D&m$/fw$HPpMR@^vדY4>7\C;D :u9ȲPu%=G8%@3 ! {=ykmtґ5G?~ !vU(lT[)QI`UoTh 'jT'{XSU@Z,{6BKsoX 01x(Ę4]zuGGdž ,[lYgΎqiHcHSg=>djU$;sƶIy`(r#+˳S?TA[b8CiY4,Z0|琡yp91kfl Dϳ^Y1ֹZwOWWW^H+EAEj]]]>k]!syݹa &˲"No 5=H5ڒ! M2o9|%C1!\DD?~ypIB01$IBTgD!&CVhڵz}eSM%W5HU9*D0~-p xW~ؒ֠o% Ҁg`\׿~)p$I)믾bXHl5iK #Qa̛N:!c\Re? V,~AGmmh;3DlgT>3`a "ջnSGdYy;?]υaM*(I,j&w?gE9@uJ u!V[aÆ,Ҵ JCJ"y<]ٳk7n2eu,ck$ A(MSN=u{׳9=Ja>n|g׶>s\gbh[P55OŢCu.y vB\=hSrDi w*yreڟ JĹ993 HTuyDa#g뜳{Wo{tPǏT*uD%QMKg\hZTYC!/ v_3}hk!+&VU! 뷰xYhT0ְ2T+^}AG*bx5zgϙS2:,cK"̒{Rx =ǔP&l!p[1K01E*)ɳ?KD1M[@toN8F|~1qB}#V4)Gmt@U:0u]ul8%41{i /g#%ƞ>vZP}ۂ gAC"V1k0|mǥ/I|t,ˢsݓeY]z7Q2, HlLLbhDiS&g{=yL@T#H qm]j BT"5,׳נx#ѡ1F!6] i 'N! \,. >7i *I9cm^`vbUщ&=,YZ3W;Z"N7eʤJRVBCd; @c 0%s2q-y[S= ,Y졇?>馛-\|G^C[CU$XBPU%+!N z1l$=y  _@_wӚo@-82.|B\8:C"@[@LWm/f/6,gnCeUC2 ѶmcrZd>XO`0s@^l3#gنwqZTEuEX!~S{Y-W<ĺ\%!4_zݚu$]q_x̻EdpY-[J8ƍ|oyI'Mؕ;gDck`@$0,'INA K[ 6︣G<3#F>`#}ӫ0Mh9qqƅQ&j:uԵkנhm QP_J.M̟?@4M+j8"Qwޙ›LZp=s$I4Iתϛ7HG2n-^h!{63U=CCdc b@TvY Ic%@6TN{uI#.QP@Bbf;YQ`NG&zASADŊg#h$a4ɤFhֽ9;LlGukvpƍ i-}{E /{x%%Y=qwk?fJsK&(J;cRk[o4ΥFP~O PU]ޞ dM~[߻;U%Ͻzkʫ׆RP7{ٿݯ~M~et[ tμvyfGg5eZX !*vn{YSA6b:V8h!{gD ڍk6< * zl$18вԖs}K/"\Mn9Oذf6g\9yco@VjUE(z@&>E ހ0ƲQ8llS(M1\jF;}D19h?oy֭[S_nmwou_~S| ZcR9^e8 p(]]VDC8EV6F" ,2nh0,"&T}ckׯ!rRLwCWqk*b(3V]vZeXn>|c-X .R#j*}Fg؎:Bd&cG=m'M8sЗ@m$S&T5k$b)̾m=hEhc A-3s5ML*lET5^cs)؟Bk$0JK(.MD Ci!/&"$n]UZe6Ԁ~DdC̣Co -) (XEj^y` =щlYdž% Ulذ9g6ᨲ q1,T4˲旃IP 1Ç iaLpҗĢ2P.N;{z$VpZ5 -iٍo\Faz,6 )koUC4U7n1cT,+^˭K*|4LBif=X0_N̅N֬>ON~}><ϼ>TW*0uR vFEM(Z%D)*U92H< )^ք}!5>ٚ-94~lB5kq~cTŦi2;1RduG;.̙3ʸ|[ 0Af@!+/JA')-[g!Q.nI%%WħVmcks!%P9qm|?5n|0쌛~ŊG2,n)}f1@6y# c8]pK}w)BUu ji XE$HܳӧϜ90w/{G-rƔg:|R")͊̔mȎ9uN<-*C gI}GKO0H̄_7vעGöJm<(2^>n:35቏`FMGDbd%֯eDE;gd V!bO4yv7PUHDI,0P\}`B|N,<8^ܝO@ć 0~3gμM !сL!^ Cn66Z6m}8'!ln9qV[ncy,U1d&W n#BA R0֛A^ h^6 A}wM<\I#Bl;rK;?n=v{ށSc17.; 7*3P0CB`J׿ͳs ]${4c ]uګw~yI|jx0 ~#w)A`VG]7ȐakvisB1^+Rh+.bQmao~k>E]KLJ8 LA6[`79B X2ڧ J2;l 1ĶF0z'b['v$GMSǸq4o1JD"ZJYHU{,j1TbZ,zov"mWV kp ǖf0IJLTT8"QvTN[TLcys?Wgn~H$Y|~Ieyu7ּ_3AY04O1:>Зs1%rPԀ5XP> dԀ/U, 5k7-c5*M6h691h- *P5iOӟ8e'C1O\elCШSO[aDɲf6y*@`8ײַ}o%YAd$"_ 2 @]O;{]fhZ͍/Ȃ1B׽Mo>ć/ti5qz-H v/C#*S&sXgx XBPW}_?`>OtS1N>8P9bU{/cEHzs%>o7.7`+ G| jAs>}9""p-$SN~˛y bC /V6*7 JEU8O7fGJ[ƶHcdH`4OLtZCB0vqPv{W;x qI+^קg+bQ;N%%D!4QĠA DL̬E6_>Y]` vu;eX% !ϽM#uwrZ:g>j,j{Nz)4o6FZfs0A4Kо{^I6i*\-LD ķLĮR3,"*TbLU'?u6E0[[I*Cs4(Wp>p~6+TEhk$)Q)!bJյ~:X}C0ujb7 BLyk,$zRg6s}W"jɓТ|Rjp(s?yի0YMU66 :cژp-q1@;;;B;ƈЈUpQ=bp2AhHA$pC3A;1B+׮vT;>.Z?>a"0A!bqwˎ$QsM~Y{/ /7o^y"T|k.\r9Ş욀Bo|!kZce?/Uf~%*ߍ)/}w@ŋ0[CY&*!e@󩓻>}کbJoy 8aF,,a)Q>~ o3e60AdEz^wƘK/Q2 ȑzŴISxx+3WQYA4fblu[v0aCe[xj1"ha#s""W(LVaZ"FvXyP!+2u\!!&>F gUV qod>v pbH3=B e֘31A6~%^r<" >Fo-+D$"*uu?49-bԂYed0XƧ?B/ŜR.h!T@ZoWZUCkfz2(?ӷfʀ6 X"@DYUUc(4X,iqkB5!eJFJ70g8 ПQPPkpJI6[ W РFm D :Z8K9 m8S!ÎɈ2׳ˮUjcfRP%_:&e"Rf-K%a,9Ul3}IHrjxcAO0>jS^Tي&1 RJh~q]w}@#u &lV¥)F9"ȍZQcXRrXO'Mwj;Y&N6=ָr i5Į뮹l҄3 z1 kgkt#5Bx#\p%b,-|2_yo~uvX0YC;xg|C YUd>Oލ,6E `{U5M$qkV AfeCĐFmT3f&m5 y^{""3Oe^g RYO/z*((6TrKb樣WG x=w#N6RUyogX?n,4IBP,=g%]vN5XZ>*փ5Ʉ7xW^ hCɋʝFo=ᘓO> u`:I\QMw)vTZ}U}gzTwcktKq[Lڨcs?mڴ.dVN =A"C ا/bɋ.jp:?Xc4UզOe_%J8ٳv2d"$Jt޼y"UCӕbK.Xrcb^s5ȻJJD1\0.zju9ǘS6 -kiE m ƈPnf/ii46f%AUwu牝aJnj'6?̘~ʎS;w11{F;Mgi2QS%jJ&g brl$I*RMՍ{/ʅ 9ag-Pf)jy"26t{E==0L:sv$*v%Y,[*`%<` S3.G99trm1 HY}7Jd\L2oäD~iNC km1P@T߳$L\}O!B<.^C l x#KެӺ#b0y}OU,3AUǨя}WC<UI!liaf}AmS#&C?ZFWgg?1BcQmD}ԨW\uMl#2!cA 6QOUfbesc [z٥WQ!Q]MM*V:3/BEy K}NzgV-tm%0PؚVA"8x)B@AC0肎Nkd94gk_栙('jGXrY}z'C҇<0+сL) 0w\rl_kuF4gO?0(7d-q$P%Q{zUd`dư0Ƈ!.9SqڴiEh]ZͲ,ۆ$X&@[`vTsY,ٜi°/maZU\ N([I$ar\ދS… {{3OgڤB$ґݯlY)x7{֘g2EL_ljTUCQD?l0 h,Z\_tY6Bu?xa/y}Foc!!\~啎*;# :Ւ݄"dž?{ߞal&0o"CHDQ>K**z{}K9}rb 11Z=\v}L&q&sXlLD82b9/~#M:*)CDXkC-[c\}5jmcic}CژeX}=D4!g,A#Bqz`ffI5]D[04Su㪄'wrĊ֮]+" a5DI9%׻{/ًcZ rպyw ea@C˺$q9h|ЂonU!eVp0s#c$5YgAUI6mIE 1N={X1(b-UGQ$B)]sDd!ǭ9g}Tji@.x/F'ԀR_e<D`mf3tP܄hʕYTݯ;$(? uWrkz7|K_Ar@%B6?aB^D<'"U ,=-PӃuhGD16l ^Z[UħE6DQRclX[=#낀o |},zdIP ]]]Ukko?mvܴ<%"P'ͯ!3eYYEҠFl)E\\NO-뮻9g`cYD$Fb>}^$O9 U9裺: XK_r^2,(1tj3dժU7=λKT(,yΔ4eo\nõXg$+x!(4)lШ0QU`ju jLG̤?rAmZX D:$ @vmPb" 19Mkc,0f]zպ-q^GۧZI:zR `3X %8q13Co Ѓwc@@ xZ$H3|<w9(o! !x"dv9s{T-RqHDTY[cguOFW^yJM̈́Kk8jƿ/#_u1 IHCΆB{r7hd)|Ibw}ƌ[7 b5&{M|/u+ػf;9>KIFvt Z>Cpp}ϳM"4yv_yZo-16y-k)Q,٨ȃ5Ip[ Ӄ;ʂі\ QGq\a:M<{t/Xcd2lB)_l&9,z0CT$đ!իn;7Iq &JH$?AcςW;W^ev76rˬuuF(!VR ?/G?|Jì*4 .eY^4c ß~Su€Dr\(+Vm*7l+*L^b6(_qe{b"cKs,Zfe̹Rѽ>w'ִcܖ?cHĂɗ0}ǝ&YGOGN׾uWoJ'l** L!K}\󨐈 .E}dYfQgN?llƬNCj~2&EK|SJx|a66`NJ IDc!&f~6ٷ1h1"*UEXL y^k!A&pzۭ6cQDV&O_sQ19!xFֹnTwWS&MF ϾZn16DҴ 7T?ٿXJb`fP_I*P|C<Fs2 0K%w=Q!!fXcJ%̓w9>ϑH'x;Yv^/REeŋwy&3"?oF 9\*{맜)Ce[eO dт72C>8J (}&Wk(Ṙ{pvuur9T5IR0!rR54/8Kϧ::F(|pi/ycK&e'7ncX~qlLn(3TIXksnkA[mAU@PԍMh$]vdɒgA/~MĀ58ELHʙ_ISzkِgGiXJfc,1eYZ|_{,*UIh*vմ(IA ~cDQ9z3fcAӧ?*{ll @ oJ56HJ(xN8gԆBekтV^v[&0@#3e>=QVfmCZ\RV 1TmRtT%I$2cmwW/|ǞV[$k? 3 ̷9+igT)\ R&+7L4b $a)iE?}BI8a˨rtIh'[fתjR6@S&k+ k3evI"dT:1Sa#J2axgj{=QI(Blܚu=w5V! P- &g,3ʜϋ!ˮ*2dXQ<Ʒϕ$&j0*LʤD8c&qA J}w՚AEyP%B[+cq*15?TYvCOjV\ 0cd1"xHq tGߵ1"d3֖ mdGnuKyFUleTY>ac-RXO{t20և4Ubp !Xya3x "2"(@wmo/]"2Bɫ).TbĆo6*)"$Cu!$IX8n㎏|Ϝ9"bv%M3朧!N~+!$ID!b*O85"%R2ƘBԭ%֯__U @h?mjyY( 1M,l  !;I,Ry1(Qλ}w5;lZ)gWWψ"G8eϙwwRՊ:뜳k?tݚUU꒗*ѐjR-z.fƍ#PA3nC /tjy9{2ՑRw0s>PQ6 8k.*UEE.2)qKٳgǨ V1@g|>CP/efC?_- ФsIPU~Z!Q`!QE%B"b-&5+v@}Q4Y2oyC HT$˲_I9A#cf]׿|޻T%4?/y~77Mq2V!3Y+R|$۹6(bD֚ML9cz}ƞJV0ar3 K0ل Dll_14ԁl?JB@Uk}cs޹d85vP J}z7T "=!D2O9o\Xf1eF ?b5 7?@P"얭^S>>@A,e P`E^'&6fƈ*>֥0ch#&v 1yd)Ȩzg(ݻ)$2 l>0d18&`ll09'& s0A$$$PNvgcvNwBNp7iow{'OV.f,Kd1鳈{￟cI`﬿ރwa lj/z,^ED1v6n u1Q)MbIA1tIS12U0hG>tֲ>4ײTvmG/g4E6jwaSSSPhƎ[&Lg(h=fs΂o妛vk#/KP UCYήz髜3U)< "ʂ/4 rJE$ɟe!(΂I'#^/01Vsmy6_/ @F[BP$K }OUtzBꃋ#"U&@ ,pFb9H+wVYeÐ0slflTUH+RδcDF2Ky!x/b^JȪjNoI4 A~םF />2T_j#m+9A\^u&}A` `VmW |tm %m+N\A1B'Lj_33"2B#qރFN.vTṫoWcIbEgue*0x}/{=\oceUӬrѕ.N׋"Dw\s cm$mw U@Aq-aÆIUHh$HbCs4WܝjEk==]f2yHrM@A9AlB@HePҿo>bR-['L>' g*JUB$0GҾ~$"2ĀGE!s UBҼ3T%Qaԓz HhfPQwTL5"0$9gu1 Ξm{ډ> @V7F⬕J^r] J!ld͋港2n>|]d>ZH#IA=ac5`jVL=6`#㳤!ER^Jiڱc䏌VI%)oFA.9@5w?rҏ޻i stETHŰD4xu e̬Gǜ2tذ AULAy3k9*w538BY]qՕXVB J㿮%%+^l8l"sEo-j> " %_gY% 9`ٲq!)ug, ZOIXI"JP# c=_m_>o]gM7?>m?ǜAdZ[569T( HE֐Vr)K喖AXdcH絖̚5shh +. UxȤYZCT|Dlwegev S6ѿIDATQaˏ>G( f|h,aVg']BW,0i^cU-2;[GTO8Vw EGcя~3O;I}^π޳_-ID5/˴߇O @ ll "~[ 0Ĉ\*wΩ 3AK}aӧΨ}1Y\YFDι1cֿ.ӦpܘTMR-9PE@L%ZG?ћ븟­:IRg8gT` jg߿ #qSԣ瞈MT/Dcǎ[qe}Ȕ  d|yh!`]~{ᄏ!]FlK E.ͼE^Jve~f8ηL0*K-1SN~ۭcG |N=|4 H¢<d$CXߗ]w9@Y4rӋD%zŗJ'"yy Ɣ ȮV * ʽ Zoeڙ{ }>uL2$᧟AYTZK=QaI& .*N2DJ~K$I:Ut.&5= /iRJkB955˲'N$4ȈȖ[n=f̘v"㽆c"oتƚȐ+ 6l",ˊ EUc'k" y Ya4֡gQ1QMx ɷ7&xO`b"2Q1] t_(_gc"26Ppl-KȒADd 6lؿT*5Ζ>gYC)T !+gبlo?a\%6 k:*MVYn{on/ u-[2V|22B@L 3۽x} oT.^0쓎HG @]E/  u/9;X\ArQ |Vp/y (=gDH _6`EKDMMM:#EFT.R SS,iewQ{$Yver>Ωȁ,Rqbjkk*$@E4e"뜵fA3fL>!8K8g ~+h 3/KZk7V@[UX+uC2P *H~yV(v r]~Ic? UifgLgg7D5 HAH<НÚ-SG`!<[PQ,3t/jL C{ݨegH-<;{JD %%0^h ںٱ|i/%#xTMc&Hhkofm:osJN6W_cfˣuڜyk"6F=i~K,DB09s8В+SUÆ Jr<䈿o?V$(@H3ׄm0Q2K &|I9c$iۋ@4xr>S\E |i=xݏ=o~G}sZ,I+pхT2b(In̳Hc>_lg s+Rlh_^Ͽk4EnBj=c~ؚ:~S!!iU|Am1*/LnK/n`nEݮ 7M_=Hk΢<{l0rN BǮ @祱b33;okLk[kQ(XQai#|Xl4dɰOOyG|qc bR0YaĈWAUֿ=X\,VHNs'. 5w.n7eGۤ^7R nKS/m '{gE9ANɓ'#/$7N$Zl(OPO9[oύN|ܶqϙ=IP^j ;MYlv,hpeNpoKiV4͔ ya():`;UQו ,4 Zf1Wor̾r472P2꤇;\лKOt^# y;B{Z|-x@;&%"xӕusU:C/`y=T}r.T ϲZjΟqK@}1YZ[Zv!$>݆pEɫksno~3luY'?A~gA8 ΡIb-8c{A!J^Ըz/{ ꃖ6l( K-R&e P!e Y3jl` wI (A\3NEQ}&Z rz֏>('VU7n,PݨRuEnECz}c K9=d>*j/{C5 EcIkۯgZ[إtL2c1=w/ڎ!)!,b>t:>w FW|! W}>dDq옔X`Oa"k&N T{:g4r{2r{[)IYfZX(02ˢrP. lE dE+5k:둍*@T3%|Fh}f8hJ>=!*Y$ VU60!6Fد TuVPLyir^*}fTD $jok A\ I>6u*4M#W޼ BG/!W͢gza>ԧi'O:(V4a;]#dj L=)Z%VbGA$=}GЁŎO,ϭzx'#x2uƘ?|7yW_}u֌WԒKt‰!>cU`/: 2fܼ<9xXk1S5[>-ny j , !ឿ[#;惱U r ޲nՇT.90wp6 I,M h7ʿgBC1 "ʶTjdM,ek,yL\|19[c,aVj^tҔiM-c`jDd kO$Y:GYj%G"(1O4iV4MJL o߻:c(r 3:$N&"g56q@T<,?W $A6\n+>sl+ϟqу% \E,J0F$I xk>|7r9 ^lSNi'Il~wԟs * ӌţoaJ%$jnEiJΆ*T#9*\VRyvai/ZZZS}dzZ#.vatmbA>MYa@.2vsϿ@R@lE30,:?mU(dɯw%QB8scJ"Ɣ[40`'M2(FW_$ Vٳ,- b1XgU"}pV\=2aM A`Ճ:(`YfAkAeaqTF~X;kd{wmm1ꫯK4x#"`vy{;v`LSv͏[6Bye=C\y7V.3~hf?3ß7}ɿ}Ö%2*JdO{8Y} C!0ğ1#ȈhNQJBE:.GC&6zPJ\;aP !`AB|ϰ0l}$P(,!t)f]r@m^{-c;+ռ820poZbgq\(l~jmm+%jB$ :c֬O;jפW  տjR{iG[ZKY"a_uګ-y"㯭RX9A"އC AP.na!MSb ȗ &, EJt^yo" #!g / -!/An)H`G87>5$Yz]{M΃u ^5D"Ysf?ԳO>3?Ys^|}-N*UZl(yIQ֛$fZ31t \"2x_gNW>dD~o 7 87B45B<7`B0 lM單u0z\ $uh\*&MjTckHSNl>>.F>zw‰'*>MR݋/x%͜5 ڍud%׿YK0{ɧT^$r.-[~fQr"+]pif_DکL )qjKy"%5t]wҹ70\{g}V9qʗ]v+r98~i˯VT&|EV|vQG-D BS^wAC\ DLһs7yM7 =[n{Fߝߌ(_Zg4I\!KݜHHʬ?r悁W}ʵR|ZNo55K1`x܇zM7;⬋&28y<)+}V3# ~eIDq]Zʹ coЇQRDRYh*@Los QC$Z3dozM7]'k%~GKis߽vHhhl 7pu]w٧vFw޴>L`TŧBa†0 e9RA7VYK/CZ?77-ߎ>}Y>}Ǔ&L?s춲/S,4|-s*BK $\.:>x0*jɟ}mAu-(soDPe✳3k׍ {ugX lȂrRa!!+^Gu||/46`@BC䢆DJ ҩe_UA. J?Q4((6P(4|5~Wʙ:HP.TnÒK QU-gAZUjYgu-H=ߏ=FQ5;CAq\TtY &+3s4X0+RB,$J(9#/BCe pС *+̂9=(Xe` cf@@+%gej@fo~SBH{_QTQ6>(! 3/BSo !axd1#+/oG, !r 1|c^pĤ*|#0`{^~ȭz{n_Z85 o^>\[Ddw|'LUOqOTcpݷK:Nŗ\϶13@f̚]>xz ATT /v|Et#>cz1(gOUfС Ј,D$M P^TryY:7tfo[B`:!hʺBC Ǚe 0iU4H%(6/~Ѳ-0ce>|e@D~Yﻙ=YC2ǵH^ȆȬYslc[c:fL6 sǝw{猱ӧ|ǶjU"^b%&M)Ⱥk{[o<3b;n]zvy9!E٦z$gzaii8oS%% B4{oBC]^rߩ?QPֆ:Ov!޶ J2<eUj1YBHؑ#oC?l5VS]8+ svǓ-SQiQK"D5YbL,~lc?7GG.;Xn?Zk֚K.dX(fLK/曰s;f[m# P($2uHYu6v۬Yꄷ h+KJsT$@Oh!L}a*hX"2>z[cMO~=<VZ۝Б HHƚ3#8.տc8`O=Ѽ(DN޼qfȠW]>sd@& |͔Ca&V`{uf٠AƌOˈ7G*3$q,"*LP5XBRn[wu~ޫjۜSfuqS %i"Z!+'H][~wq +PBU#rCnN ADz?~=Qr56}{#F*Ļ8!{Li!RGrG9LpWZc({0ѥDjcUў@BC3a%SkM,5Ҕ(2"8KˢEѸz~Ĭ~1aߐ_zY(vɎ!׽˯8h׾r>E @DƏQm*x~_TgpJ^²l!*jX{"T#@ąB AO;G\kbC[*TT !((p #=e SNgXPhp6wcBZc5 Ek2fCȴi{[oMUp_Nί9$*믿z߁reyE IZLF^7{nQgJU+T3ʷ^sru柞GX@g"ިo:j̳}m<ƅe-*oIa  pI'}v̹JVa#0+l#R?)?*^z6s.lh5U3B=G."2Ak,ĩ[l+s 9gAowzmXgA%C d]O>_Ԟ=7SM%J"$- R}! ~wM7쳴n`Έ~^3g>qR;D|%ʄȎzsvJy)=/` ,ˠ`^3?@(N:c̏'M1s挙3g͘9sg̜9Y,7\50ƚVT*KtG={lbv:kkM~WXvT^Q4-@e7 ʫ_ꗳg~fSn[W_}2GD-6ysY(ͫq]U2‰H=Z?57L}DDrmiPl/?믽fcף׿ѯX+CC-pG T *A *m{2E[cad ;g _QCC>|YmQ6:n¤PpnMIr s_B`g$&#O*u'N|܉'fu ^Я)J[E:2)_7 ^krmlҎBb|eN9~sZtʫ0>VTE;effV*eY[ou{~}31>d|Y}+H((S@C3X7 ~/b |{m٦+3vu|`\|w$"w}wa-߿?R2ە^vyðZk<]1uIo> EHQ)Q!c kvƔF߾]$s/z1J4TXɑG{L^"y[Mǭ*(yc IU S(rSBҵlD7?p" P%{3<3K+ RH^%U!0lU7tS9e $D$KJE7^}yT>ѻXh+U u!6]Jr1 b늍#F<@MUnuq&)<52GƩcyaKv4 e&J/~˯~5i$ٌ3OEn_uՖ[nBHD*&MK#ʂq wG ғO<>\>?wޅnnJgrᇶ̚3aDIӔ&eA\|=E00k&BdW˾IC`1FU@Տ^{AZk.nequQ!SFIDiΝSm)[ß{2ߑ{%@*gϞyU[q<|^z 1{knqֽi 7|M7޻VlL'8}k{=+|k|oPP,$IR\_\4([/eN;.K?V[!!`gV4Ms<%A._[e#6;E[ͥQBU޷둻- g 1>S2U *.sJ1vܸqH3PWHЇX,ThM>ͱ>ײ  ˢ)t뢥\oe;NJ 2a/*{|0Ŧ%@DO1!D ktE9[~Sf*! r575:ƲxB @╋Fp|m:?:gNk5ZКw\{-?y⛏" k`wgh^D [_ F̯ȭPh([n֨7 Jl#a*&)J/X33v~[u:H}TƧ2J'HH@% UګyUeQ]*إll1YRfPE? > d `{ Y& 9šub1w)Ac "Y"c|e2|CC,"Y|j"~~zD}<{WJB37kjjd?~sk~Xk;y]|C c{N1 Q2A)@"r6O<ٳZBîv-0JFQQ8M9w@6Sv cu !w Kz~4Ϛ %vJ~}:26E"mlj"&LaG Dֹ<3?cs%/-wQQ$(L>:FmM$T ^.bc;4@|wAIҠJU8y Tǂ>k>d N?(tMvw{,v}ס}_0 ك89V8툴K}IgF>]YR(F[ma+iDDO8%-٧7]bl/(o 4(yPk}~~^{{^{>' :P\&C r橧W$"3"*{KPQ%&PISO&|Q-BI9%mL[nη7UapeCzs|@ =ZUhaW483_WAU++|P}gʖN:!|~MMBvԩ}x"I"~ꩧn/0 P xFQEј1cFf0 VO5_W_MJDVeD`Vs---r}Ϭկ}%;臕;eMT,MYP%⋦NV!+Kk{sSpm*J@*Bs9D,EϲrZk-19T%io@D6r OS"d~sk[KK/̒K/ӎlu+{`/)@ &*{9=T+~`cB>y}"ѷ/Z=օ`gi305Ջ;ch 5)(wG"EV;*z!zg'OTxckXcMXՠ'?Oxscsg>bʲd_ۣsZƀ^8*\agme뮹SeH)ՐhADB~BBܙwN]W=儓DC)p¥]GEOޓ+_ wqG}9-SN/ ;[ѴjNo U i-C4wr[dMS[!r΍,vXW VET>}F3}1pvΕ~"$>(]~UAI}5/RKK+:Z_DZpÍTZf *"†UQgL/:lxIR{I93l sq{Z|v1f6袋/1Bl G^p}̱\bUWYV_^:OGZ騅DPYyk,i Y Ψmngp͵3>l5"!˲=3k]}e΃gI-l4M5P%-"dyEPI9s' Cu)p_$fCDO:Ҡy L/N5= dd:iO<;cJ7踱&L%x""h٪$IX$&\Ol ɛryS&O^aN;<JI&/ /C>ֺkDDFiCtdAҐx|.R_lߤÆ !Bdm{Kr$P@Tv)ӧO˂M5i~O.k *?`ZkSKg%F;g,{y{MwӅt饷wxCer :.Uȳ¢%(Ba0k,cl=Ʋ>BigD᫈^a1CaK5|A?8"A;UQK \.:4twuٕ1A 0sl9MھgyꒃFYdi:?ީܕ(ADDgoz~o@LA% ZN#rIG"Zt!il7DVzwjjUa-6v=feU0@*=v3$Pz=?uDx >ԘUU}^vaLLq|`k g P0KǹJD0Y"BXgu~ӟ攠=c= qTWA|{{ߗZzI,Hԃ}|/-{WPg8O5{cC~hM<ڪP$ѥ^ҥDX3d0-Ά` $0g[.l93ady'o cDŽ ȽGn}@V7 sUvOclc–[m_}>,rG^{I ]ĊBJN7P "YX!;/ݮy0^8S= UBҿ3N;skQ(H;kIjH = ,.1UDyg3 Be"9z[7H Uu^uPe"~"BCUi~ Ͳo_VUObZ7f̘nK}n#`؊SG$IR(8{7nJٳg}N€!C#&W=3Ͽ8/,?X}'7OԺcA4%_O\ֹG~떭ꫯI5i6gs?˭]NU4u.p=' AXawI4h .:L.5thL)6\.V6:E$dlL}X vz'򨴵NE@d#i'H\(@ǟxu.E'HmrcH鿪 f'OC~wT9R&3?CF+k6Cj|r\b-O93N=m  !sZۢ>渓@Tmů^WN;Ǎyݷ#ksMM!?Dƛo*@lSOwv^D` #`ۯ Q R/})y\H0E@DއJ4K,3͸A$AtU_ r"B >:e,ymαB{>@!u7FU ŝweХsi|$U/2IsZ[& *JiS'4;?1gܯEE6g@Va{c!ѩ'[S,H: T\^Zr$KiFqZj* C>`& jp_;nlUa4Muyz4Y ~qOUfռCm;of=k!/D& wlH$0sZiE_8B,W((]@Kzhwg{'79" u"J_~tM(3%1IElg|̙sMt]rng* Gwl3i%gHE+;?Q#>Ӳ$a?eLPu0'||:mP*OpQdI$v䘣'%{^ݟ=*.M)D!ILT57X/Yc5WxyL$A ǯ]l(>'6YuA-dďN]`H(PR(iRU2&xu$`/D!NUE2Nhѷ G/R.|D؅PE_j+cxf}ŭrI'9PE;&D8SƌL$` T =qꆤCFC;>KF^JSO{^$hGLq^no;h#[*T5tJ$Yg{ G{%RμPE[3l4&v6?\d3d>pEƫP(ȑdӟ+ y]on" xjz?9{\Eο6G7IRqu Pbm=~Eq΀Գ{~Qio 'ۯ{qǶ`pI@8Kv_BmۘuN*%q93Z\+0qk>O$7!d^4e`^  ʫ=AI6*Ger!D|IRjln<ē2?ك/cU UϜN[H0._}%+) $>;S=V(D IX_[yEV5-E.虙يHN !3s짟~n ME*p.⨱_믝:e믿^(,c 'D?<޳b.t*JRm-g~j!騭pNR8>YnB°as)sv|mFA$?܏43vzbӘ} u!H4YtӦ=\(6 ŊY>__)˲F e ч}1PCTIˆـ[Je^ x+, ₋"KDm6k}/r/`, [ |TQ"f5xу:lDIu. 5XC|РAއnw= qVx Tn\`&s"gΜ9x0;llt""MMMȵ`TQFQiy+ eD3yAkzz:k7V oO:崿7csN^<85B|?X(4i*"FOj,evF&K/3tlUĀQd *JL֘ bulJiI Yg=nj3s!*(V`a%>6X ̧<Wju$|&>(O8!gE"R`,.%g rmGSȷIWkY."3O͵6ȕV@ky_u>c\~'xY 콰9-m{?u$CE!cBw=GWZi$I$IOG43.R|3x$!}0nAe.RrT iky1"jcG$l %xF1ckԧ{.* )Q}Fg\3 `ij<[nuIXcDS屩 T ʌY3n Cm'}1Pxo6 $|Ơ\YRqXu|$4Ma,R#ff$B0kJ046k&DB$HR];'ܢ}Ea~I )3rz|`a )Ӧ;#KƤI*9ߗkddRS!p^6aYW]q5|6dC 2,`„ }NUsVLJ`bPcg@s>2-e}'} v rq[v4 /﹕W]M`\Z7\+]l!Dw/!UVnok)T<>/U̜(*4 ~PtltFֹ 8Gr>a>-؋?nwŦ,x ^/¿;oտKۨ@ +'Iҿ;$lV|--- 3_DPhׯ}'g@hll(<夺9kEÆ cJ(g#Ie+N8 -` )94c }>j[q6'ezLd6FC ,k5G6>-_߫c/fͲ]ljU.WUZ:2ߞ>Ilk!wCąL^h+kv1NkWk%ymbύ|{ڞujwSq8VX,A˟4r* SW2Òu$`@n{t>ZڑRKEHyJ X88(q0w+̙ uհTK09ǔDSKo>g 7V[G) Ia#xQYi |kރ8nlJ5 L:_/IXsI1;c]CC.wW=_hspOe]6@YiNCZuLP$I|͜2u$9sZ6AD꽝+kk0_Lw\#!ג)Mqޙr'qi>.ig;sSCc_P+0aaA9/5 ޡ(TʌIIS_1Csfx;*t.^ifɒDSE^XFqA -g^swt4eYBw[o~KrӍber}MKRQȿ"r̈́n.Ik6H7Ѷ z a5Wvv-Tu+>qV@*2Oz B'r=|k[֯HUa.u4to} kPWL !W&^gc2tu='wrRᥗLtHfOc׭ޏ~<)_V|6{>05DN*Y9z-Yظ, ?# V8W AD}xg[?_pe\A!xgC,BF01D 0|iYH`r &Uhs4N V$?s *f/?#;oo,A269],JMP*=3mH DDQѐ^>|I} &u.;dcc 8~w !2}SEt`VJ'rz aЉl)!gPe"KUܐغzPK*  _f$wsv7N>;Dd cGBРD& qѕ!G$I$u]ABdI'%?x}")␙,r>=U%{e_3j88Kl\! ƔJ `Tls.1/uh[w-C?YzTdV@GZ 6QĎȈP^\7^c_>j(0z~:(T }޵iqDn B!Q!%Pᢍ6affΧM)=Q՘w7מG%UfH*w7J864I#$ҷ+/ ^GQAS&_pEǢ_LUS |g= \P-Cw$Zdu%@`89?_S=61+>CɹUC,!+eΝ?x T!v[oUui 3VXaGyĸkD"^s7hiR~)PQ] YXDKm0q]ǞziZoU<%I|]yiy}o H>sE\% Ǐ?+ܢfi\(yiijhl/ٙ> /_u|{^{#9% ScLR*{Q +JWjkKRY6՗Ǥb\z;sS!p1H9X*ZܥX8E^>*=uځlmOc2GԊҪ.D!͚Řzև^\LKH,~={Hy6R~pꩧpQU6@nTjKҦY3[gϘ&Cʊ:+uc(Ć6rY4}ÇeW6'*MpӤ@ $ ;(T;EX`7[2 \P׵~?J__E &U_7TtDH- %LjfZvVr-TTWZ:sf̾[oEC B=TsG!X8POJHR?jm/544s:BXșLn(O?BX)2!vT2(={v{KBC=/(''2׿z s3q4tw>+@|\FT'm"O*+A3fRߝ8,Ig1Yp@9I+vKa*L E˫uN8^LMZ;EUuņxCx ibv]匵Ũǽ٠=|p:)Ak;RDƾ?t& "ވ5-7\ӯi( ,3<3a\l3F4 iD} $]I֥VĬZkȩJE!mlj2e5- /Vs[ar͑o=;y/Ů|m!:-4Kȝq)SQP`{ȡϚ3;W'(9&bSIJc}Uma@wT|֩L&M\lzMak%ħS%|SAXf4eUH#+AB=LQJLLlHԲ93oFa0ī5x@[ƱqƦ.z韟\T`k5\7O~z*^CjGmwRF0Bыю:v l#%c\Dy "ClL`/+<.Y>(cLjHB7 Z߸k>,X b 4J/(D5/]'#;SFU1&)0VqGQd-u1Lkkß=gA뭻GwP(A̧Zɼ*>g' $ E?z/oYr`x7xྞίl ʉpO≠Ld !l 8ho2dH!nx7F>@c`대nGmM!b/T4]LzJ#GiJ x?~} S0.c]z'L|վqI' 2%C-,rYvPQew& ;_؀T /TJ<4Cg~nRA'<1I^UqkE"1-)I*A >̅>)K.=-&Њ+H~tE! Ptl^]0ṃ>> TN`Q_~葇 '}M3a}__9Ϻv2ڱ`c!i?Ӟz#XzPW^'NȆU 1SŗS8ςjz. :dȠ믾<_]l7dSc ?JQZˉW)V?678YHE$I'oҹuPV0S v?b+߷WoA%x7WƊ,)QOmNG qٯ~vhlpGum40qT)\̓q֞{YEgBR{W!!<أ/Oe*1,}@28~;g̘l8"dor)wz{{rDExHRT~sn7sUOo y$nذa"ȲUׇE">74d"J0lRMP8L6[LO[o/֋^RDzwJ.Raf!$ ߏ˟t[8*\Z!- !?flK$嶵\8yK.k?iL:N^5kQke=2x@ZjuE2^.u=555eeVJBȂ_~Y< oT xub-52$\QgeVj}X8PyxH\KC90ʋ ^|O>̳J:LY 6zVv?/`. 1UsHzSDDu֞~ 1L#Dd>i뮺gCLq;x1?a'W*o񣝆*}>kHFpUW A|f(x۝)Q4lмvsfij‰7~,sGJQhL >tzJ=-MS{rHJٵ7>SWW.љ#ފ\ScN85 AgJ5$AOp&OꠁU>Jܯq7N}f&.]wSJR{iAO?48+eFo^s(2"JgO<,bfu\`d!R)nl2oއʹ4*rΩ(vK.< eXk-3d6QR!pilh~Ne5+ `$j#O& U^*RQ^zů!#3e|^U?i%+innLe]ܞuqƫ|ck'?:4%&2~\UmjCEU-c6}_jBCbHTu֜N>{NKI ֤i l bN:X Rxewsg*~}a]BL j0K}:V4UQKeVe=\4hm[q%1T٩P2c^ɬکTӽ: AwkOo H  |U$A ܛMiSnmNR )aXcLȲ$?Ƹ;N;.uvo6U{wxCĺP =u7S /!oכּe3wIA˟-|ǏBcQ0mO-rXbR&ؿ3Ny䱧{࡙X/ƹP.Q;{i',ipN!*3`Ged?+m͉H2 nv,d(YZN >z]l&, dN#vtR8T D4!Κ9SNE.R r(YWIKmgIb{v{٩yUt+g2e-73'NBDG: !uF H9dHog wW^~2Oz(nrߦP$൓y~՘wG47D!y+z1E//~ȐAx]HӬXA%7tHOΙrjԜXT^ ȃ}W),%XSP*lv׿d&bsmDO?52" SOwթ7p@ }W3R?w"h% G2fc3EGT(CE3Ehmm{ԵSX9YKDчaư6l$,:hO$IPMuHtgLgrYd\{9;pߟ ^z@(B^5֩ 5ӑ|(Vh% $Xk6miƦ7,:J#Ld[CHg#^zy7D0utVXQ2oUrq.x}/!!,!3x _s7ny@b Y >((dv^?["_pE2A qkMM*, RJL/O7y ,@VR^AEs^wۧfP>oYTE6#,OwabL*}ԱaȄ@sLawrj `\W)*@0O~A+/,IsqT:ds.Qe]vUW+kP2?qc?浼J\&"bbBb&<(uO=sג A)%u+@HCӝwzI32y%nFyUEwܻN;W W d煙 6Sgj M>'!@ s̕Nql2#^;5UhOrEJwY^W_ >~nW\aRط!FC;˯l cA;% lǓv[l: YHKrdiuZp嗏5裎߯%! W\"x_@@`Ce+GeR+BCKL>Eɣcƌޘ*P@e_\Džtc.r)娠B59N."&cb !l}~wcTDLȺ_H|}'jsb1hΑ'%vmw뭷J*dԩ#G7[Ca*+ ^뫼ֻd: K ŖXX+n=s՗7 $;o;q„n*"kWaeo~& .yQgefU6uC<AY:c:6gn+>I8@e"B8kL<R,UBb l暏憬~'pSSc%U '"c EZ+:T@/u蛳YƆ$*1Uʏ+B6rօ! kl*nB~u, N|I{ib)x_!cD8RwqrGUu"  1d16&'s4c0$9EBBv?fwoO: Ixo~p۝S] % JP8tLս0X`B%;ׇsLS !h F^`<۳1_};C|.1"*{R!X"ҝvѰ!2z󝉟|@q: | Oα507,,"^܏?әe!:ṡzds;b776cWYP%g+6TۥC=r { dmK['Y2s| |# OۙYC8Ҳ/ez r H3D5P% /+kŢ,ک:}c!ry%c /<1ZkQmz 6e}m#}Q:%Ě,m_n!gvZSbq.o;Ff&O98뼗^~VjOҎFc,ykKɢb'JҵM], H6_x襗^5)50,^f s: ްh S՘ukY'Lx7kD:$r#6ƭD|[k[]Kn׈2B@/3[=vwZqPD5yC}2m##<6mƬ9*0PK o?|`r\zCU3|[S.eGw|/k2KeY2Gi+|8!nIqtԠ + ed&˭sE뺍Y%faG@V6Xv! fܰ=zQmA|Y' b(YvüHwMuV{[T\F+ӁN}@gD y/1BB,%)q-5w睵!HIc:X0PPvIZǞxp!2R.\ΗSK\t8NH"Y25D&eY/TSy^eo|ё'*rAȚr֮$Doa&bib%FV ɣJ`1L>=+B76NGcרե}ת*3 ~dIѥƥ"_}\D#ۗ:EsJ*u wuoy["DI0DX>2H!(v&%K#_zGjK\DHe݌B!)nK.FhQ1H[mq(BTtfG?]P lDc IY+{;˃ *_junn˜n}"ry,O?IUրtl l̎RQ%WE0؜^s :J`,*WT+kI)DEHU$WЪ"Мr[7T,(YX7҂ƒP=FL}Saۛnj?ufKr2jk}*s(FEC"q윋.Q)xR ƨQ:T,Ԏ|9&bP(5${fmf2`YHT"LL6,f-~VD`;O #U%Ku>}ݷV2J͈{.x_|,3t x뭷,[ouwE5*,ݿ'׿a[t:NX}""C*jrqłkAVcHU#9YoZ@Rj+f@aL^c/abk˟[CɸtоgK5G/ǟGoզBb*JƋ[$m~JMs3PH.gM?KeJGQA B$,0DRм ߏ U$`]hL1{ܗ_w{2j1|zoN`:qJ101&g\_J"ԜeٌYmǍpss}V^ MI%$)z5*QBĶָ4DJG=eֈ#&}:QCd(\C+gKy4\S 1}YK h.N_%5*sIXL~÷Ԡ.#[D#yz C /uFj+O~t59hW}KXē=wZݩ;yvm WW\vʫ.wIP=h >sEebSZ.[gWoߌ1",3t:)1E Ҟ}dÏcNLuY6h:9Fm=93V=^j֬2GH(Ir`GsYf cO<տoźU" /g|#OJv P0 Hb5,dϿʔʨBsgBEƥ:ՠArMy#FdZ{#_nfGc@OPBTVphk)LĕZ jd:Kzt\ }ѵtqfă " J([ԝ]N5q[#_}xnBD%N > M}A$1tewBU8'%IDAT!Mow޲b0}eqP4,Zs/}8Ö<4}G>FhսWji[x%f ZﴽZb@9e8s6U%7?Vя>1c]~f0/DHјDP[+:l7,bpCի/>&_N0հ3O? ϵPD_Hp2+-ɟNu > sLdZT0D̀g1*h{nA;mf¨T;:΅+lz~PS~2dȼ洗y(_:$Lܿ LH7z_W1U_7rfQ.+K׺խN[Ea.^h7['η3rm9^ X&- p%L; wDIW^~eرq`.T;+( WLakƎKo~c|ӎ]Uc*w`)7ΫZm[Lʐ\-8*CUH>C>><¬Y4A*) M?٬J?:xI]LQ:~#P/t OCbe\qszJf\FؘL5Eb^_veo`  PL  --(b5ۥ۵R, Ѿl*89q4$gACDhmmy'[}XJ67y=/r˔Jnv&(k|ȑ/K:?s$dmF/{(FCU+*atñ|^M*YQzaŸ [sO>y- - 7n1c5Bc֗3_MgvuŜO,(Qe Ug7gs?tRކ?b *u ƐF1Wy-Ie[= +$$ 1qR2,|yu+Ju0/,9 ]a$T,"jwz;9љK:6rC[zϪ:ws3#b&mwΕ)s3T*QfGd ?ʪ],JW[sqc ˓'O~T帱2C= 1E;kbP QVy/ŲC4`՗O\ŌE]43gN|i*jZ}"lU^:uҠ1h 6Xpwi].@N/|J|:;;>ϋ \: rs^Y{f}j+p˖v[%u:fŀ\zY'/F;"١_t?Je6)L-*QfeaIQCl.yݵx#3t^aG=8duFX .I_}E(>F`ܒz@C4HCb9IlLJdIwDiS2cCy'ag7DT,4=ӧO3ƒ$n[ >dY&QZ}ޫ/1bp _hDD^qD"Q4IT)/h#,3ߋ#~o 'oPKPчw*1 Ш朴h/8ɿ=ڿ^bDf+'9%i3ff>+:c?@X]Խ,ϳ4ON׻P@[a./<_HE\R5Ag !gyGckWp.8,`&U0{1clb۰`4xyT[ncPiSzwTw#kty䶯 }rV(5b93*e8Y4mko7S&Oj./胱!g!Zb T'Wsb[oܞ@"jATn `Ȑ%5LU$aƚ^zi]Y3D/LhJ?Kԅ\(T5Ѹ~}Cت<:ryZy(" S\zIDkIV]i9SK1` *$}Qǟ0vJ`kȰ&P9M+R(U2YfN:xn喍iB 1 }W} aWg<)P)UQhD$`K/tEZ,fcټa>{CY+X fCc K._""jk BLli9̳_|U2ָB‚ZjV>c`ӵz믳2K{UJM]l|-f^DA X!>8fDl)m|o{~ C=w?*R@#zv>efv!xUe ZfG}Uj%6Iӳ8m Tv ZNyFBr}s`c5%^7l͖7F@7hLkOI y"'\EUxE{ّAd|XI6~ԓ9f-0`noo[nt7 XUj=e"1g0KN}P o^s>M!1ː8q wx @\~;D V",f`n?̗˪ o?1(N:,M\ëlTl b >'@̶Pg]b<ߧ} ZfiklGeh֧SL1xM7 >Vksbk`lve֪b>/DQ R_[ P^B4zϿ<HD"߁5SrL>HW J1d٥3X3\l`vhu{2ySbVF,N 胷z$Ɛ'C/+RUUO,!a{Q\'2璊ٖ2b}oGx„C~s''JnY@(1cKKkkn ^u]׌|V_s(%9uN̎-+Nss^SvAl (hsp+%$%w!4.m*?c<$B˟y饌dT *GHׂ0Z`BTC_~XUi "1Fblmi9ν6k$"-7$dRbcbƎM}Z;J] 搃ayּnEB`ը$F*/um7_W\EQbY؏BVsRk  Sf |ب%ƾ{lP({K C0 ,S߮8|w+2)bYq l3S~w%r;`|cqwSUtNW>)pu[nGWLAY+>KgѱR C+RI$"@js5ۻF"tέgW(8t!2ƀIB]~˭{w1rL(nj/hUVFJ7/ y|$ /y駅l}@>YmPzwe 'P(km0a{~e1 /@Ġ._=591f*3zOU9 \,ۣ_{om<2fT&QRRșܣeJ+Sj,}yo{ʂ"') CYtuGzM3AW4p_}7Ce ԻBmxa IR^x؇te)Xh& |QؿXPUV\~DAƕ#;sU>$ϲLD^zfSz믳RKfiB`o5yҔt.x/oJl2)QqPiR"9KI3Dq˭cFjZ1DVڌdW{O|>jh*}:%:D!2"dcy= `@X,c*t}H45Ֆ[駟nii?D``C-;6FXs)L8qVUU8K΂.{MR,:'ZU  dQ:裏N2eM6?10{yhY&J| >q_x9-P a`?~_n[|{1ǝi0|ɇq1cCZ&&0zEHOJ+ ffBΚ0aC0L t^!,vw z/anT:sP%c\[n~ɧSQQJ\@HC3 beq#S8 ;gci]v2Tg^Usm RwTon>{%DN$Aιg`fUQ+:*:re*٨*Ȫ@T6Ư|d`ȋMǨAw~nj\sZDB1M}Ĉl^G1Dk3[|Gy2HV`Mq ?gH hJ (ぱdlK9;}wGj, ֐Ơ@N9eeRSMe@BU=JF_hDUW[; s9h؏DBΕ \[{M猐/̪>Ac@@D(k< _d_tyTonW+\,!HL]jm3lskEs%K}=j?~r{ZO?s?D.]\uCDֱavmg @{N],11YkXzqcgR%\sMgϽwm!7s#c1*e1Gs Pl"2ӧϼֻz= FX?]` BEye|N;{j<8x_x|Hw.*hdI6h+12XS:$Fk ds2=*̫|iG̥߾/lw2T)qNk)xP,U Ĕg4kvDhTu;l2Hlgegy􏎉J5UE"|gk"vӍ`WXqɓ'3,‹oU1?EGxW]u TߙgU%b(Y:e?7G1h@Qbj3?r9M\]X`D !hǼEEX#{o7|1=3.I$16&y? >h`hيrݘWzկ (Xd/8mRC.":p ɭ~wJcǎͲSTڇiB"!- ݎ_a9qۦτܐ~&M̛b1ƛC%Q\H=l ,~(>U8x=j}f]3Ԛ;~ŗ涶TʆA3:3nO>: T΢)qbI<̳=I0|qGX**`uFD@7:k$A^}E}* &`5*_ZCTW \j*"\'1#DV]wa'gOq;6r0z!,:c(cI/WmŌ$ɓ&x'OnokK$d/wA=>Gt)gcğzұ_ # fWe.?A1HݯP7Hk/=lRʙwg(2Ezg˃ca]8}ƌ{0qJ`ӏ>ҋ.!-]ĠΙ!OTYœ!B"`@Cw0?`-uz޹z|u2VQcE,b `3[$DoQ%PqUEQ:kSnQKkICQw~[CAc@ 'O?픐3CT8AR1`\XX^%ɲooRgy3Q|9͗sVseQ 6ߞzYc:gCb4{~0f8gny=wWUcI5Xc<@~R p),2 T(qey _0=qlakk/xtq"A>?w]wt-ܤU ? 7|Ǜo@N1j[LdxgEƐa(a6)Ĩtɧꩳʩ!rJř-=|e i܍SRN5 *l kL4c>"b6j')@v"f'iЁ @}mA%emI]J2ۇ6 0|l\nyE[1+%;h]vؾ4 .T,iVF U☊HCdMb:ɂV4K|r2,fOK{ Yfuύ|qL5;iifY@UNQ!ξ~kh͗)9/"$FH;q_Ǒz]^u 3gg?Y# bϽz[爈S6"kͷW:0MS9kUU$H9/b-),;S;iTQ)'Ь3z{c&l!K vTkM3m^A4"QQC]ƘE a66S@BzsrZ> ;B1m`(n m*C}42tۺB!B T (TO ܞhaƼ7Z. Ct)/5@yC~Ew֤fb刣Zc{C<eA /:c3#D]8s$M=s#ZrRSQUTa`VXaUV0aje?MӇ~h뭶왪>F{oWF1v>NWq"rOQf|sӍo۵,Q` `"*qޘ=ڵN#8gjk `%>J$ADo\XD(Ťx-9x0D'O -mG{96tȒ9 &ڟ}CM\]t՟~g*7*.s+{ 59Ҋhw9kH6mjЛ*) f4촻!4%H,*82͗*huVK;`c`s[Gέ!Orl"҂5 IǝִBUV~wrk.3yehSH9Q}V*f^se@=kO&U!bf@"ZS iI q64hP^ Qi/F`N*`0(vq7YAaq7vFQHa@^Z~aW g2 b'.7za豫~kX{|=̋3 UfQb4 A}q2lFǘ?9׏ f~9DY>+:[Q3U.&x5&?O~t -7theᱛgƢ>'K!,ZZ9g11P6lК}Q}J\ssqDX}=>v "HHUpRfRc{ϲDc>)X8 xe[XM1,e( ,xIΜ^R@HQ3k1)X"ϺG=eT"WjRY;YpR}+ٝwޓ3JԾFHFSwzP"(^fb2 f|Pg}"mv=cǍ'd!,1q^tigJ!D3H\m9]/,˲,M:l T{+ Kp-n E(J_@V1*+/I'"W"KE$1v.&!z < ZeVʶ*ƚ_* sCyBc@26x#CIpUw%b.MCάjd,>[E0IN8-9Qij¨y_̼R~3YSNnio-yn5z{swE'N>Jn^.ҝt)sIw} %V[mC !,y4]Cj(<Ŧ6MrH|ŧ;u1R^s% guB'O;fhQl-PC@`Ԩ[|\΂G)?(MS;餓:R7,vYIJ*-Dt;/U-޷YU\[ڂh[Ɩ[|PZ7bq|mp5PչԤi{{ |@Dh~xu3~-30j`^И4+t&kg`L1rK,`(І_".N^UPmiNlOS%DUETq-8#c;q&ɱGNsu/UXCQ`wiqHE^ U+ H :sf#,x%[2D e~G/O,]b 5O7V#,$/oAv+ϊqjEܢW*Hl-3-k+A8׿5 Zw% pTYaPlDXغwA@" m84=b̈ujgر_>3 L i+LH"I ''U!K^vQ%`>sf̚D"r1! ,Bgk2mJ]2GU! "ƏJ k4RK]Kv2=Mal{Gv3UcH*K]s%> !2ͦ| md@5ٺƳ^t ͦ'\\h%*x啷o >%"n9sfMJhĄqSn LB &fSM;o}ƱĆuơ>r9˯\q"bCb`bb97^{FDV䣬đL6n.V".mͷI`ǾC}?0wQyƼv>\yȻTh4ABnJ\T(̱(BVgo\L2_iU3&!NXL;Z"0/>}-kz<ǣ1tߟH4Kc 5:faCYE +Bɀ7g~Qe|F)g1C*!zjFҔ\BD$SO?a<ĺ,,5`>6lBMoU\{_{_QQэ7d1,":}'x2\Y?goU@h XA%w#}doJ XRqzL|׃+Ԟ{|T*?c=Y{ej1MR':?Sc2HvqKwѠιR._AD2H թSuqSM|Tߞ|MZv!O0znI5x.ӤWa,{\ݶ[,K#k`( ktCf" }g8s] T}{\>cakXk-,*@iEYm7SNgf"%)BS-.TjĎc[J.G~Jlmϻ`=qDֱ5GuteDD<ǩBDzA2[4KNb٨HP0_sè#0B#֯@^՛y?!7Xoظw8;S |骹T cL(G߶_noW*sHӧ1ޙ3ZDb}>=ˮ$*aعwN[2_+ :tj{ۉuR}W{me՘$˲%,Z e2DѓO9)L .?mm"ys'|ʔSf~wŗD0QZ`GcQFAQuU%/|5:a G9iw J7_}Pը-@ B0k~,V uҿLq6I_^o Gg!"8Cԓ=#|<"Ço}rK^$AT٩\ AY]wuռl\xE4%0܈#ȢPЧ`s]@xWa B%)*=|EgĪ.Ή>G4& >x˰НvVcemr2azUWY`.6.,YeL2-U4ߞxog#XD0 xe_{aը1gO*s]L CRWy!KM$! ,~13gCռOwW$zfHd=B4gHDO,G?E10úqc?f^!#b}ZZ.լXT:SR8^\i;)Q,~~cLPHJLtI'Kc~wnȆ6d>tJF DFZgKM{o$KYuU|1Ju?o2ӊ8%[M"&v_>rC:I պ:G 0S0y>b&};[51wxGzQֹBދZ(ڣ98!M /\nee2F,۱;K*]NY|g Dj҂MDT۳4?+|T#h7'OK 29lXTP&*=STZ3eꔾbH{7!'6}Y6yChol56\jB0 1Ʒ~*0G4OjT,\Uqfc̍7ꫯZgI=N-!l5®'>;B:}Xac|{QT$W c]ǟx"eD}0jU28;#FUGS8[|$uH;!vUl rxiLg8v sǿw!"ˆ]H{ׅWL!RSw_CPk)Ĩ}P.gurшJ0l$c}rg ԪjRz*`Vbeդy/`4dnCDzg|j(́(zvߑ4?>@Ĭ1+K 88˳>ɰMeK.cԘ'X@D&qF <CIs_YoNm+YnU #U4ekZw~>@ Qr.X9 ]wWkI}]wQ DA֧~No6IQͺ.IQBGf +/K#%_JcƎ?n1caf4MnIXb6#^av$D&U0[X ֘s=w`,{!Mqv`Ex{*c8=vdc#1ч}t,6^a}T}**@tϥY cmNUCJPְ|RE(i{w3:i٥.Ў^,g<`yA"JA4zȁvL00Y="]}^+ʸYJyЭḱ -U0Tl  :`d4(P֘@UxBTRRV~?C8[P[|MTBTT3 i/ B@Ya@L3fg|O<;79UVHs1aq @sB2(%WW^z_YUy3˭"l]SnJ8C H{ıǴ41XkTEg2W\1&M͈vӀ/xaMtr, +.͒k8z1)U|Yus00_x`/Œ.6acmC!lz_Ǜo3,F[];;CMl(o _xN?`ѹÚQa>_vSeZX}Ucg#"AdOH9K;7=hү4ec9#:{=w1 +Jh9XD% jr N <0xruZ6RXH@R1WV]#8/2 )Ss 3[fMKZ:_1)+$dlIcAY~ܱZpEje Jdx .]PIJWujl{02,_̳m>#27rk ܪn?@ w廉5IN'{.Z̶ W\vciՇMEMC T~Z۳nͷ&SG'o  FLgBT3qtФO>i;̳V_}, $>+f>:Wx~{qA!BOWR,’/ T' 3N?}bI><̳MeiFztۨiY\e[ /߿SXXK-~]v"̮&TAՏc !ưW6̂I]ZEcY7fa]ݵui`! 7Q.; OX*~ܘ1"IY3"t֑a}; ֖c?aڌQصg>2glUS%u'2Bz:uz{~҉eUbS^yW[ j*.*W{OD/'ɏ:m-iTμ>ETUd 4%x T2Tb6wGH+ #rG[kI"N}V_}yT)֠] 5I BY`O=yI's+|XguN?cr)%!}1^}"':<*7]t 42QR>wRq#U-dT~qŗ&łszD@E Z]"GmZY~ LĉݾSVn#)g*, `ʲRDP9_lRd蒣9C&3BȢJ3!)3fr)*-L緪R%M . Yh Yj>TLŠJƐʫOyKngJWa(mݎ iTIPቩ(auRUh\<&kݒE>Uɻ1Us*#YW"e6CG 3<fd}D$:c+- @}VqC,wg&jsz'k|nG]#M\ifSR8'}4ab'b&(!k 1D眄)[_k '3Nbkkۦnr)8D2$\/lة  !ؾZ~Q <5prkg{|4>_~U󷎆.m&_Z0[sۂk/_s?d2MW^ #ZWUb(<"[m{キ=Zs XyrW=܋'O3.qRDLJ $@41n@ȲϹg!՞x~wF(j>+8ԉ (G}tMiʆCЎٝ2,M~WY!7G}xǝ#OĸD%<) =>$w.݅5kvPlj_v'PtP/ ~߾~s|^hnj_8)D,מּy"<>-˂FR ]h32FUAP(C_3ߨOLg`elXz3ڒzsĬ"HUz\O7B淇TVM*;R `$I]@_C`~q Q7Rxܴ/ZYJV+5}D9 {Dq#K|ڂsˈY8÷s.7gg­wzߞj{{+?\vM"ÕP< ޗ?(yikR0=?ox '|ZbLGI‡E³OkJH>}sZz7y הqo{ Ydy}Ͻ=S(`U1⮗^zIŢ֡CE+tu 1 10>sK\¬Xsz>C}9$*Ăs\l1R9'n T%pM|g=l]V^+7{ܤo K,Jbq5jПag;l.I򩿒MS)*=x]z^Kjf/ޞz21{qTކk/ 2[OZuycbJj`œtI 9Ը-ƼhkknCWf"kPQv.5s:rl!B2y'5l,ѳi.B &$MH ~FpΒ't]3yMuC(HE^{ 7ܐ믷<YD(0v⤉O?wo-%Eް5= %<[ n]9 T: YV$Jֈ"~)R~wD,v[h+ط~kCǎ: isH`,33祥4_aȐ!=qI93_ѤՕ`'[$>imG_mE јb WJ3C3}4޻Q$@(*JJDX nK.50IexG_yUU(3 JD!Hg| E%&sQg3FUAJV.uQȕE]]Jy)U=8/bM s8?T(IN:1Lz| SZwr WUDFU5D*CG;e=xY'>hE._TB>}p㟮?|- RUbczm3P.?ǘ5`r-s^^q~~!(jɾ"8l1urtÕ$`1O'{}yא(i|kfccNJ# /&Q}TtdT|g`#uuҚע/k|H={XQ>{\`/#D3GÏHӴ\n(I\i˟pb#j)>j4҇u¤=#abGB0c{7G23ۦLǰj~?KL|nYN:PV%U6FħmKk\v>SQr95VU]zȐ!"h짷z۴ӻ;(">dgcT5)XؽG?scBJlKO&O:#*="3$ j,y\,%֐FP<ɤ\{3]M{΅o`r/>` >VP]/_guB ^>ΜW={Q1QT`Q 6IOB<[h@4g~w|<) 4Ba꼂]loç5A_l/~71 k~Uyo[o߉% #".  fcmv9:kǪnZ 㲒(8vg>‹q CMjzj Uub&Q-"ZUe7?_y;ng@0l=ę9欧Cb l9卩Ā4 Y-Q{{ũSgΘ>+dZZ{Cp\mCj)s#gR "N1l-*GW^|r]q@*gLoSqSL/ ݶ-瞿;|AgBRUxT}?Vs={\lA&K&ɂĂA*P%"J" LGrބxuE6`qXW?!g6>ېi{Aurksڬ3pr>OߒrMNJ K ?FX+%(?k`F1:0k!@ec5l VLYj #_- {&_WĖ3gL9ezGN<5*&P!ʲ\G ^aTs ;gk W3QT5V#lfw=xUr{KUB<qPhii5ƢԶ(,QG{P,ZcN9oca09I-mtT %s|CD@ пO-:[Hj或r[d1BQwMH3 Znŝ?ُq{kx`$C7Ï}^b qPxUtȀ+ :mfښkQHGwe}G4WZK 60pkNjd"oꢘF97^cU XpE9 4ֿꕔ"ށFz߅!nmyiN*<4?\]vx6Ƹ>{Kκ.W 0/ ȺU~UuITȸ}|ɧL6K$"F52İK/ư53ذMѮP""> |yg0w޻ߑQUr5=cML#iF17]_*Ϝ9c~ W攒|(n;ýZf^*`PVQgXCݶ[ʳxW@MLVB ҎKk,|2iꭷ>eTf!Vg180)*jWH8{k,1IDkKo::ٞ!̡XW683OT5F%EO1'@Kidv0dg.*8 5VDb1F q~?;oo=Tɖ$"QռAˀT5MӺ0 GQrxcE:fbeUuO$D6mU!JxCP2ҕJ2zo@dg%}>2SaMb?,U;T~u2CbCqlqlk1c<ۊj}Ph*my7gcNr/p>( kQ%tp;ɹWҫϐg}Cճ/W^KlTQ$@o:XQ#SD6̰ )BL>kl! )Bo.6&F籉nk4& ,hH ~c6P-/.gs!r+mc;ee\@\ TG4%U߀fkhݠg9O~+/<=vo)ɨxU*ITV !<=*qEJd8?SGP*|N8Bᢋ.hcLZ bvۈ;qZWF ֊Az6MY#+Q30ڳFYмœy}]bY(ذ5>9J6Ggg ]tͯ} 0JXZV+a(s‹6ItRnBĂ㮻kTJCİ;vl/uTP_I\5z.2e!Ws$D4rk2K-v 80)z4;+D1u?ګPe  ǫ>ڦΟkS0m5v9v? rW_B3G,vWLc}5DTγ Y*re%I"#d/dBbJ~:N̂+9?$q>dMҮl+lTccڲưLަ`8x+,[|`_PNGkߟe1\2ռ&0+bF0l5q뮵x֦lo'?. L|"D(?DI@[W剕{èO<ā0ޒ{{!c[[ێ?功T^a!z߸e Mw!)YksޙT$u ""2}޻@i|G䞎u׺iTetgJB~MB_)`E&@ DF*$"+@T$<^x& =ć rڹ3R ƫr>v){`" Pa\wݽЯ_.v2;EBV(eX 4ϹjO (RCT.V+Qk !0xV;ggM Cͯ}jԇbGu{ŢJK[@XbEj`!.Բ-} t+;jokb 7AW/Kpe^FB(!2l7 @*Tp)G]UB`&2mivoOzwbBcH͡%Dt-+ *7r瞗)fDgC\zA^x2C>Z`(E,8 w,O5𿌆pcQs+p_?A;Db>Ǟ*hvn}ʪ2r^XT2Q/.^mD}him`@C`fҍьY3&LPpQ n_| yk=W_uuy*r%akЉZɓ&[lg޹(;wuVYieAv楿bcɄIWT}ߗ_Vo!KzY{[:vW-p'SYdM,onJ3wY˯HlDc\wTG ij-WΜ93֚kY0f<45[/Q$DeD+EUW鲋V,!|̀֞~ʩK-98*9hS}|9粳!˨>]`UV@1 2uNxIU9Az:t6L׿CRSZ뢨l:Pq] 1i"I7=su[^Yɛ'c{J{k}%<#+4n1ŷW/KZ.0Y4MD&~[o.:=Q-JpA[ebG~wG3OƥHV 5WqIj3(eYJ3f̺BD0վ[m-8P&N˃{4iʲi%_^HG2lm2_tTX3ז X|O=oy/MϬS=YZ;yy=>8g5TZfÆ ,bGeML0ekUU>cc -gܜmYaok8RId)S%XӍT.Lk ū}V _c($pY&O#!c.65gvۍc߽ϩ=~iVn8dȐ(BBm?MJE}0INt >V/$jW' SrPhyԥj i˥Ā rU>{ɧƎC09M5 1&Y挳;Ns$3J!ߞ}=}ذV^A5 Xf똉km/}!g6B9 F$Fo.1t9M%@ EFLUWkekyD@L&A`ͷoWu #1T1DĐCVU&e8}P0nǝ @̵%8R"5ЭC;,dU}&!komah24z@DDZtߞp-70DCV$Nq&ޫRu˗ U{rlj(jfcElƛlw)Dl8!Ji7)oabʳ\">(TϺ^6+~}-V_c,V\!xJo4լ !R1jev8@kr5)C$1jwʛ{:O O>~dHN.əT}Aw4TU'?*Zps@ S * Ș0z ef+>xe2m!@b̛ǚ#r@мQ  ??h0DUjQqG?y= "A*G P笊lk_h\ֶLrGVɦհU(X97ubRW}Vs:Ô{*+ALF*1TB_`5A{Jl56e'Yď>x/MK֢&E->Vۍ0QaIbP E@I 7ڌyNS1z.~x]#ScX rFU/IY%UOιiڴwlT* AUm"?ĤPkL񑇟oI>˟De펵R#FP_GyD'3 ,\xqQW;\q;u{oGB+ 1M]UojdeX3|5ȢڻP(5;,FUe(_i5?P50ʾ77jr1x+,W\ړO%UPX_hakYk\.ګ/> p k+υM&xibV!mO?4 uIR{f"p䜃HF@9HDTTT *2HAr.tWٽ^߼O?NwWWW}}59߂lVvw%F-6ɂI)(f!Pp_ׁ"j=cq*Æ}_Yj+s ۣM7_N4g:',@$F-3B*O{AGh 2qF8k<3~aB\*@%f#1 FQYn!'&[ Vҷye]g-1E(I_{ٕW_ӰVϜslB9bR,3W҈ʴddZDb,{ y'yvۏ}рW\jN;z-Z/Ew}N;loz?U& z[hѤ)sd1,B kߧ_|ђgQa ރhO#ǍG}cְK09kG?|+ػ/1nf18@DcSN{od ->5Jk)DH)Hh(!wEA1 25#kUBTV%jZyb>9Zx*vz(h   /"@)h@C||x'Ǝ6&=:P8}9XUEw߭d @g9U+q%1 Qїy6f^Qx V7fQ.k+F @j!nI{@cg>ǧ?3 jFh_mg>8Xhg \T-s_e=eD3/o'FsA42;A U5_a/cbH{w̭_ QV9AhNK&: K4~)IK%Rw=_(#*YXYM/P\Cj(Z|k_C`ءS粎L"!_?@ A7f}[l1DHv`6 l:bbZRbe +١A2X1-V1OWnĜUL!3K//~ A<.^p"Y3:,σBĩgq+J@MzG=ya\F0'8_n\u?zDhRPq3F$BYT9nTC.{|b'U(X3mjwoIXEZkUV]Oѹu.켏v)q&6T[ C)dqb-{~_?eYiYuw;,C<^`B4Mgf)AI/ERx4 B>杷/n[V2mF`+ >fcc`Mu\yUj%NMSN:q;oXҙReM,jCE@Uc >y^6䀯~U;Ԛ"_ig)k/>bNfa2$|5V*mԤ?!:X*뮷[|8 i[m@  HpAՐ䞛o]B2s|ٳ[~Q8. OӧsC-{7:XRn*1$h _?21A$tײ?^Ul"ưvv9yώfIEc?:GC1 4@zԩ'jJ^7b);'"M2P(Q$AbQU&9aLb"EXzO_TMjVL;gwT[| k-TՎ-?ͲwlwD9_k?׾>dF,jcL"z 0H|Ԟ g0=|uփuFomLOFc  v y! R2,)qr>񸀋lBHĸZ|#:z dl#>1R +C]qw˭wIE6fLABZL}}S?9'3[ז915? Zb庿vLL`ج#tcp6U2ftxI$Q /&Ĵkܔ~F3w0@咪:J IO>uVIDsMZvQGiH,AcTUU""IW]w M+A$)ϋ]I^߿ `fgki촱1˜K.aM$Im/"\Rʀd}jzͻEmJb^jG D":KAї#@ 46&:DVEUo媏1@,k2C3$Qchĸdea=!AԟW~A@(i %&eRB.z%|}%qNDe_kH\b-؀ UriqǟQT dKWӪf4.@U4U$IsDl8nNSͫL1uy)B ySϭM}3j|FTL (cK~aHpν{`{Yj5B06<"2`i*"y5]. .(b[`E1->c?aɈz @aADl T)1T;ܭBdK*I7hͶ(IDATxUQD@ƨ QWኼ{QUbQD5َ|\K3f" -ṕEyEdmYaa Dl@TJ ۘ'M B$/R`~gk3Xcꙏmj't &w׮*܇$ , [I^a>/P ח\ t((ח>>je}D~,+n]ҸFmRAɸ;~^AET%*rKwuv//| Q#ؠי2UKvvygbdJ2\)E"^~eU 4U%X'M9C{zR4>2/#@DR$֞9!$IlÙN?lRqT]׹OTCβLSjQD190GS dXUO;iU{WP艹uQXBm=YFTe;oFj+R[PV6DŽpΧOC 2[ %T[9d -6\yoװ!&2Ѓ>x&. 6ژ,fst1K}vg& 7؁VR[O=Uߖ*Dc(ttJ (o~UHF%R+Ad^vݿE ; in40 Vs~se:%⦅ad׿&3 >ho䤓O|˯OC:2H7GqZ56I]k!& Q$UQ\A29$4uT#܏Ndƌ3mj1. C~e$j1t-G{K=,B[q .j9CX4M1uvwO>DW }JFI/XzMgm}JA%R+ҿ7CC(`[ډXU斪̎ٞrK.=& β5C9 C?φav"""Q$J1$TP_fr%@9@cDyD^Eb6 WŘwww1у?P\9<7.Ymնrgp e6m cHLͯ}?}^/!K1, AU1hâfng푅5V$FW,#JїI*T(F\y͵a@,1!ɋ~ i4Z|\0⡯"EhZb[r\:aQ(= _~gqZ)cE${vjpzk`5YjE4=R}zv~&'@2we_Rgđ'&Y-C ۼz]:54J8H>bͷz¸<#tW Dju&*ӏ &kuvefy8f 2i "}#(F Zi|68$ExqI;Cbc-j+c!QE )>|F+O}v?JR%TU2bay7r0T+>Fξ7a\>czodֺ4ƖEǢh;_F@ԧ\T*;ŗ^Fha 1kzmPe6FLtjT&Uf6d{ȐbKhp?oHZA.=Ƽ3~x 4;;0}BOC 8C~GgU1ˉ̱?я 9l" U"rmSL׾f֚=I!뭼or^Ĩ;qi/@X!-KM "jug=6ګCm+셠xy}?^|J 5I/HK_ϒ,gErzHR&MQ& "ڿdt!Z'mg@L4A]sϟ0ab-WAg^wZ:3޲qfy`2MLN^g9jhj]T*=!F07`9f>1FFm &CLQHALD+$dYYπC1hTzH;;]%*BTY|H  !Esf Q6a,D,*c\H!R%m 1J&kxG"`D0av(f:> Dx1ԥFgO"efUQ"}[m+6*(+ `8>PFߗ0ȔI1;Xv ./!E)U|7F޴ F&l$yf1 LY?sh'? [4s`/+NpoV*e?^3{ д>y" %> HbT Ѹԥ՛nc-~(XƍU4.6bs,+Lf92D ƛmf߾^r|Δ:hcNvXP\%'?4 }_BfL7Sѷ`Ԩeܐ|5x0;o/gŬjZv鯽FsK!J%;b]q.Ul[ǞpRG}ۊBD 5|?}J!Aht[U~qƾz5mJ#Cb_Rp4J5lAgiU`V?N{w%E5u%c#AIRJ"@Ms/. zQP/HYF|y[kKL2ԁZV^B8bGK~'uؤl~?ܱL!J*G.zB֡uDnC.l c| $/|ff>ӇtUۨiNȺUc>cq./z˪B!V̎-jJa5gi!U(Ԅ?]; [x`,b}G@ >))E~#)Sً<@L̪f#G gC2*Շ|(g"ҏBRmO=la Q:+~"G%{Gsdc)C5I :+UhYE"awtgyWըr4yi:k>O>t7 CE3-}Q(HQ):|˟y8K ԯ0N_ Jf\4,cK@*bЄ~Q\k@,+_DQ`O} y_7<D%Uc\z`ǧJQ8}ؐy<}!(q λ,1j(5}~Ԓk2+4g!aq \oԹ4%Ҵ@9s9g'LI/ Q`eֽGu"ֵp4(mF<ȁ=ggƨHjLe%|VQ؜P }ɽi%LB2PC  Dg褱o~Sk=lȱ5 rjIHX/Fδ,{:"Zܧ @ Y(Q9D?خ̤Ue&|Ye3HPD93Ǽ3ck#`%__! Ia zqiSTʬees,) LM'TI{5r CN""Pt p@eET*CKD"Fxveu։JR0af`UZ[6}HlUȘ lGE+H!U̙~r CyL?VRg ۣ hz 65jG~( ƈ(q6>zOpiE$G=[s b;KȬmim,'V  :#;0% *~2sGE5ZǤU` jJT6KhÆY@=pU3Y&l~֛3u_9ׯDLg" )"֘E3i(ō!uVFn(!vtr HCo1!@5CcacUh䮓N8^0 M$rZ3ZySPv΄賐 g3KR2 a Z嚫4MMa`Yr!O.c\j?|C|W[o=6TmRQ7݌rtzcpI84M$cQ,t׻MrQ?D i͑Jl?ۣ3Bc. JKT,cJVʦ!)܉RlY‡6444菙9uɓO> 0RYG~+,9SN69餓5|0xGŠH|kEJB!n`LusOmB,rڈ72EmIe٪+sϚyكF.+G|͛o6 M-yO=U_z堲:YC`J-& nRGB\PB}c.ZMJP5ϪJIX-Dֺ3ƾĐ  CbeWW b"hFO~|w܄~v?n_7nygĉ:rJy0 0! |F,<:tmQ/طC9 lBDT~r/j/*[{Vi_"9GgV H5M\z 'NDC`cT<oץhח]~ÇHZX۹E*9c ̖`AsIUtFwΡ\ueXqssԒhb=%XBAi!cD6{3N<1Rjlx}CÔԇxn1ler*jDywV\qm5SjIwK߰Iܪ0(2[l.d^ ֻm!zDDn眇)鹧/7j$^8  ¦ZuZֶ:QbYkva;}j4~zs[GX_jGscJf"#^CcZ/! 4 n{?odie-4='99~?)BQϛӤswĚ33*j?G;n,Du$A[2(YN8Yk5j=LE,֭ߡl hDɴ Ҋ{ UX&>3.Rl&N/Ǝ9uԴRir0F"<3;bdy`)* 쳎ho!ʬRI06vV[vP &#*`FU%37SUICQOcɲ+s12/Qn~l(ԺwQ@̔vAղc;NĐ!Z'êf\;u9f?2@Eٺ[oa}UT"ԀkѩQ# +D0QyR,Y}-q|K_+: ~YvecmfƟکEő g3ALRhA.:+<$JLD~7!ֱB 2/r1>4~\ayJ,7pV Oʪ!h0 H TAf zpi};k2-f?(t,1i~ ,">uH,%I B_Vh{7ǸN;+B8W"RIRCzj#q'w%Z7Ӱ=s~KҤQ0~CUTҲ> }`\۴(nޭ>}蘓N}s{ ~k$g9䓻.=Wo ]uV{莠M̢ؗ=K./e>21@0i/2Y AH_ַ.nZ*T5eJ{4u 飲Dj&OGԋO2- "),WQyf̢>MX3vV  2cL2eJɧn vK* 7fЈs ˤ0C$^qgP$.2TX 9{sǭ_6J6J_D !7tDYa'w BYQӘl`Bт le\0U8&͂g11QB F.>`?qTqM8#F?ClPYm(mS=0À ֠3D޼xڙr] ~zE{]qZ}Jq#pȷtk$Mm w:lD+Eg1]leq{"Y? ;'fuR]Lugd/[ٽ,n)A$1"ݴIH%*b2̶iU|=௺x "f\:[3M"vR\ +NrI'ǂkHQT@QE#Q6#{-:jiDGsNzBR0i/(Bto;"$UPZkYƊ/,E:&y~Riim,(P>xGX[)>dkWυm%&mH%6Jut|fϑuJ,i%+z v 6fDJ@ Hԣ#Ăz~dz%!GzXZ%"5 cT+;9M$F&ARD@ eS*>{x՗m{`!u6fMZr1jU,͠q>6IB^I)_>i 0 n^6dTb\$?\}x@~{}?$iUUsb&IuuΞ2P4k>Wv?qBPs-~mo1ZUkӮ%peO @ԊKq@ MٮsfC1;|{^* c8/Tof;;wTm">GҁZ>r?яv 2ϿKO>8|` 43KoB\rIɾ FY%IW( v}Q]hNJ-- Kklf[ZEQ 4k}0yBq啖> =mڣƢC:*[HPR"! ~_xq6[5x6G=IA/ˑ[}SY1;gsSc%+_'> di DL߀k|3Z{e:EYyu?5Xq e>V;^fq`fR"1n;c&WAhy.1羧n3r316~SjZVrzj]]]&O-q(g/*l v6<|x5/-J0Jlm7c/"c 9 !뮛6~|xceY !¥mQLo#&"јXw |*_o[m,h;m,`7ju{Wo <5=ԓ!4{&Lk$>b}og6~ĺCX—}?vXAHCۋJ,k@8j{EI4\כ>v]3zؤY.}hH4a{k<=cڋSƌ17{GiȍsllQ@ƀq)ro—F R?]ED*Rh <5#N| ?5@u6{y b6ֹ=ԣ~}gI}GZ*p' \ +lTö /~$ZI"Ht^4k@)8|HA;kKŇ֥ I6&L;^_\]-ؔ4 cȰ0ܫ5Rf 9j FHL.nG) N36YJJU)K`-_z&OY) |~fkY6԰20@I_=d9B6Z1j_BFC#:FƢTRҊpuy$Z ]^}F"IA0v68A%zANQ;FMdL$|k=eC߽(-iz1Rmj{Euw&"0TquUW,=r7ƾVt}F+*3! ITe߿s,'lͯ~s>߼ҫo>c8%B> \假;ßz9|2'j$Q5V1"9G QIVe.̙gK/1Y>ZK?8/FMJӴ~!RzoU1kMבg$R6KpI տ:`V êϲ F ~80y$Hl4Zއ6\baS +ѬƉ1禮٠fBP~d<2]!4>a6!UW5T_g;AhcI4Cl)s2곅O|{e( pTyauRZ(a1*cO٤$6B`up%s BnaG-H+`8YjXh,5`~у~pX.8Ja1bUS^/mzCSJ%i]kaC p BGwj/\%D }N+|9C H*P 2t~s 1!тUU eƘ6^=>˯T2-,S3`N c k c?UB%7VBV3|ء7A-d99i PTrVWٌCgY&* QUJI Z!cDȔD *֖9A$d)lG(:HD,.Ĥn]?. Z027S!"6ĈLB`g4\1QՈҫbk! 4IWB,`x"T2BZ9=@RbJRʙ虳`Β}IO!DR²߇#. !QhZ1_LPECAv-jQiZ bRHn|8?lВeQ~;i1t]wEƜAQW_7YgYV3h :16i¶Ȳ xӍ7x;T֫+cye|鋟:tR0Z,k "nof#& 1- կ/k|ןUǿepyZ+ti2ݺ% Ψu?:ui,1Ґ1gơ}S{Xjy硧^;A%Y<}hN\ZBU/ nA *^g _rI+BZZG\c/~O%Կino.eS9^{՟c=ϋ, ǒ|̸m^|)Z VĤFX;CwWZ HYK:[Rjcb$ȪQTŘ7^*ҩY%K/Q!SC E0lM#u/?VP4[B=7Ǵk;-1lh`PWw̳ɣaÆDUcz_^#>7ގd5إExrnLf!Dcc]g$a'g#9gA 7ɎVϷiw&Bِ}=mc'5o4ܨQM*}}s!`b6Y]_澔hP0nM* pjw,Gu3Qc.ZGgv/m1?h;m,0l,K/3QA[g y=1G\^.p`ޛܒo/A5&k-wTy~Il%"c UWZt33#%y~đ%@%DExAX:_BV[gYrpkT;CY杵0ԩF~~poN: /Q| ?bå~c$OC  : 䣖k`KYaʈA2d  )+^_L:];a„ &{'M|Gn;Ut~;G%KUa[?sAHiUBKu 7yzw9GDҁ#pR GpG?>PP("lD"THg-^6(V ?zC$CjbuK^{퍓O9 `+MJڊS/]SD42~Y:b!c}l;:V+'ǎ{뙧#1e3YZAEBEߓWk@<!eu?@+"JiqDD%ZpCTW'irb7j*l~0qX2/6J0|3:.r|眡8s6>^3"V e9߅Nb[ӌ51Hb)eaw!Clk5q 3VYurHAZ˂38*F D:OpݕV\8ce{ϻcvwEb7T*콯j׿{=1BTDt8޻LQD Q.ůg}PX*eG{YK4IMPWk߹q =& ;%Cdf5Ƅ] j1$\wx6foM7 h8d Q.C7H䁻$(zz 6` fCb]ւs)&A!VY}Ueݵk(Q=4iVr"s6XoAm BbnDDHeH:7B㓏<B&QFecpscȰeXCU ViA>e XuBJ"-QATsςS!MLoh)@*PoƗŇđj A$ȔS)S& E"2ܪy1>o6ggߏڑΔ:͢G=s]/g,@SQ9Ad@euYib]cOL44Fdy,-ba9| }>O.O/`׃Fk NI8|˭ƹ3b|PI 6` r: Ė q>e@ 6uJ_0AV2w0E6?Đ)<g}SX$|o[HGe]%E&ga7̯J/$P'y #kcY$|UyW UrSu>\4\QU|=7mT"Xb%> k]XIwNp)gqI _9 1H-Uzs=HW"up( d#° Ac'xb{SkuT=HSH?7PjjYТUDU7p5fU6I,h*~4e E?C!=MɳZ̽1Ɩ!>#^|ӧeӶ1(Eū(TcQ-̲Et"5u[f lz9]_j5*Hͺl:|==} f}c[mQIt^hc㜟9.4P$M`yY~~&s|HG?–[l%H"%6uXe |EfV ΍wQQ!S 6@A>A#Ph):.+,Th6ڸ_ xO-[|ݵSzYj\[`i92xDwD05j߉G|͵/fJ?ɚkjT1ƠQCό1o8Q8ƛn[N!!weX2Hӊn:믷ƛ;Zli L.͟PD%аM6\>{E)~VL"A@"r6>D,IQqIˌZgnl cᣲqcy36ALh6S@_W74jAG iX)@DLD74&B;Kw%.Ǝl5  6|'@ƦU"򈐸ᦛ)"f0`ب3-H8oIH(APՒg DF6sÂʤpPˎZDxk"` 4un`c?;mEC.GȈ'R u*Dfʂjhc ,pȰ{2 c<-ą2(~ jK*FzHg>_WYߐ곍j`6}*sz(@$~d+4G40kP6+(BRqYO?}}/bUviOOkZÔ]Z} e&fj5ipШ_2mpꆍID9Uƾ>\⫯ju{\utBi%s̔%.*?56T;3fCCDDw~G}r/C0❷(bR+XX#$e.< `\R|޹\{8.&R^ws`fQ$!n9l VxV1_m*j-c  'Ӻ{̱1蕍K,̛૯Zndҡ$!C:y⤉ι,`혱O;2leHY]1='qU5ײ|ŗ_:M7eQ5fck[ bp !-\5,*QDz^{cYceAk6C1dch7ّ-^柅8cB"G@$Swo}cSmRAgnf4 @Iz.I,{R c3$F "dL[o- }(&X BFӧĬ" :gؤҫ?:W¾)Kdw*{ң71!yoD48( PXc ֿu5ZeWZi%XC}C+`h-b`" 0z'|ʱ[gtܣ`{]ڃ*kJ5|zO^CYLE3nI.@CaSS\qO< 6ؠR~=ISHƎClɇԾ;vС!V =~VI*W=z_ewqSRUfZiU3(-DQĘDc,RQkv෿2#xO$x*1vv 㮻cUƢ'o35EB`RQc(Yk ]su3=Ø_w>}A 2dBUAF >k\0I=3fL}81j&I5QLeW$ʞ ehᘅ~cI>F2 $,Y#~Б8HBmR~+G z)XZXhT" k;VlQK5 ƁOt]C@羰,J65iٹk}_>!aFU&R]`VF:2&3z"c,3ƌ;~4n@ek!ӧL(/jWrv?L)եnf۝?mԊ Dق570j| K 1rÆy/ow;'-ܢՈ'6$;VcĈW㜳:1,̒s^G%Q*R(*a!N9zmRQbAkI{5fg/h-<*qW|,<@156jc7')S.,P{wO>'>ѯZA5'zMO6E W* ό_rS 4S_}>E͓|<" *n!lh_ICRHd !AF-j b5/ }ˊvVbT;'MB+6WK>ϳ,sQei&`T<(Y84M;:;.*i1&AR14hVqIǂ`٥L{1D 1^pQ /?)g3VDxgIܰ@&1d*o[)Br]`#>xƗ3%vew!fQu651.uՎ ޟ $Blr`dgvjXC:-}ᥗ^ym¤ɯ1: c|me$5|-u{vV9CP^_K跗]~ɧXd lU,nʼn? :AM;Ϡӏ?rUWj # "TukBgLxHzI f^/$@ngl߫=nVc7`:&LmpОss&~cQ_jfP qq{!bfyD7M&DYYBf{BvDaG$UT*ϳ{4BT(>_2O(x^l9Vski,dD2`aӥ[qBȂU)Ƕ (EEI >s.Fkucѓx\u(B}λc}-Æ7Z멫jVZP:::;UTT1IjKlY^z8c*)?ueyN:]]3(U1!pR!c bnV%@cK @cP !V6ĆTX!7=kdgbYuV5%`BƥO<萃~N[0@b}aתdRPHTIX o9\ UB=$CniIp!A N <h^sAJ$v41lMBTe_Elz+?)T2ʭ-.eH@FģZJFҶJmvp+Rx {}bUVXwc@:|P_}m-2.͂7!:DVmNrܚkhO,H[}bO%.hjب*8Qeb)9,(jǼo=Pm;kJ5Ƀ%HKbq1M:yN'yoBG-|Q1YwZ?~*z!R!ڻo(ѳ5{RB*DѰ gBo-w~Pyw)j=Q!Qa";Pè/X /Q6@  1B\Ueѷ_{  ڴ CgAJL4JǮjkl}?o:@֪Co}meCXh{Wm,GZ:S,1sZ{ܙPtakmj&+ qsm,(fkT+˯2,10r=>y Yi ΌY|@j&L6A$H "elޛTNC@}"QKjqHyHӦD1UCX%yOV0%De&ʢ $L^"B6d,uIbSCVc;vڗ\O}rQ#C1uM;5]ЖCxև.9~uń01٘&|ō-2CK.= 1Z,[k2`)^yQq0 d5}6Dm뿍EvZ}jc_fY ӧc{+Ç3%_w;T^˟At1"򥙡7 s8k/=#: i**C"k=z睃X:93KNb.OJ֢\)'qݻ]2!ieɑ#ޙ0Q'}r+BJ{[=,ԇl%G5*˻s;LsH\wyO?̝è*Cst뭷rAbq1G7K.)G*Վ-Ē+Wk]1S!FU1QꆭET֜O^}U&=3vɢo+lz?k* G5!$6]>@YCffxdVRAz6t96$ |l-cM}AzXuT"5hXTRjʏq5s?[y"Di%4m<vT5#GfLEA_@/}3T77>˟>gkNdF4U*ּ3< n=>ʒ6Fɚa.7}ܻ}Gf~@4G>̚R&ID56-HV?]{xh:tH:uF5ÿuЏ.;E*y?ēx,AN֝$^?nT=𑝝CVYg׬ j3rp-"F~`] *j!<oOS!\kXHܘAaQQTn&7+HI=$,Eb.sCPekӏj:4 KT5DHDCAes[h{/_6% q&lK.d1lse?fY{3aK#of?MwsNO[΂2K.SϣRKaao޶')\wX}/ )6Xh6w0P Yy~p`FHZ >2+1jBJir$I`Gm@*CUUTPxʬ`ʋt}uzzj^<{j°aذeS?;C&afo}k_8sl<c$$"`O~SO(̓?SMZi6ߢk8mZ)O8#1+->!> {.F)2}}2ju~J 5>s@hxH0@Ġx_Io=gK{5pΩuj!cԤ.|`] MVC|6/X}lФl/vDž$6Jf>w$[z(t!K,)\m,j,>sSTXkSKB}A ВF]Rn*3&P 0y3>ag0)tpӤ3>[5Va0X`dS詯1jk$*$L4A!#FRdBG"@`Xآ4ŅR(%%ɺI [ӦbЬ}qQDjS{hӃJQj04uTY5ܱ17!s2>R:!)ʨBCGIf݈>^njlNjQ"pényETDHo]PU*qE,j9|~>;lVBAVVh"-K^lGQiZE47矻ՖL>p A,tqBDb =gn) 3I%Zs>nZ.*/; j^|M6>`L;+Pq*Dѭ|XCZOʦh"I[qATgL2Kst. p# Z묇a"> Vx\plwǣY[Хw' |eYKP4e7wͷUB3__j~~O.E_gr?ZHtɇvo0SLv9slR)n- *>c3îΊEuQnxNc`F{Wf0-] LHQCFhvF-5hcqAhc1E#R-5_>@%LR0]0i($!&(gYkr290x5zn>ů󐑠DՂtb5 P0`R)/-# iJ l;1fgsnĈxL :gP+;1TeT!V аKЫ򗣏=61*sV=Itgjy&ި3[h~.KQ)~*=z̀(Tɸ4RUʐv6}6,ˌ)|} Aڢf3>ZwŘfQSs̥>8PX:uvJFgt秜zo櫭z#FtjIS\a 5$>s/p\Ϯ1$䡯JR$Qpu)TTPT9W;eRD(4߬0 I{s"$XEK-C QJV)޲[oVDZ3ԻEu_MřfbkvywBBw !܃ D`Ⱥ$?/'vV aQN~-L3m}ƼIqQK <-u%6Ir0s4FHPip䰪 P!koO=@:4!U I |;oi=rdLZQ` 1 `}K/i5*}8XA \jQ\Ryneg89]wW:0?vcU[<{d&KQ p$i% ރp/R!8#VBP{QP$[oY" GM@!*eFc3/<[o>!+ @&I=ϬQ >VjjQlͥh,bM\am?0y.1}^1u]gV뜐aKlɃw۲1-SS#\, YE OcD g">(6thA"MlCoPQ0J߁S}++Q` !Z=T@Cif"0"=nWac=bza]63:S1:Yo7D-$6so @"LZt^m$b "6ݖ #>[NY q☱cǼ8β3B`D./XܽxUW)T9 WEs;/VHw܇LRzqDg 1i0p FD}^?9~1.aC@kX'3ӧ>z-tKXi 4DR(+yaN Z>ڡ÷GaYABƍ7L04@O]2'6C뭽߾{X7[N6 ,;Jj7`r7lyܠK({ƛ/nD夒So%*5`-A ~۔IUH$BI#$BhA= -]zl"ySh2%33zMxoFUkZo}Ȁ򚩦-7}9 V Q / P\}853e:#a(Pi8@R[_F5q1uj0 HZHFZR ?CL,%Zx_9_qPHvx+UEb .kj3O&qF$i|ir1'$A0Fc8VY8hu@p_IMɁ3u0jo}z&"ܒUhv dY`nk;_<}O$u3&N"@do@>ϻC!pVFC쒟^ 1jT l &EP, C\fƄ JX#!^{ZhӀ`BY]Wf5ߨ(i|M\a7v{Nkl^7M*Zt0D,:Cr1}a-Ju!-~2U 4l8OU-Z&31yOA-6{pӍ{Eッ8l0f#D%wy_Wfp9gjmS }n;cߛ4Q%D:@ Ȉ]Q?\c i[ {PQ٫JXmvٚ}s}*M7Gm|`@R_\gj-V&[fFLaaukr8 :+yե!<ԈeJ(Z'YKx^sXoAJE:1,4}(QTykh7e$Ĭ!-s}}!l4A)xy5}s/B61 !Daegjq3mU|Wm+UhOOY@# ze ;&woU!oZJmb|1]r)$),bj=тht*鉧,UXŨWRgEvAkV̗_~)SXBg%1mW"^DSye7u:%Ւ0ale;𻫬2 AM.0\./^yd$1Kli>MSus8ߨnaܩs0i\|;:0t:N#P,W^ ϖ0ƁO*@UoMy1/_6ՙ(uGN‹ 4 ` {UW~I^ØBX^oُ~(#Az,L(Jzx{z0-{ cDdv VUD 2/(PfjKUW^j+Cz'zfْ5 f!2H,?+\J"U6p ͳ+>/&+:h}nxW>яZ6b(֊A@wۍB}Yf.@^Sh AT/utm8V,_f^ ᕡY:/>'ۋ޽u}مPšH2W PmD艈lxLŘD'o;$I02;:AJ%BnlzeU\Qc$8*cYg|}FLS7Di 1O @oG˖kYB,@ʌMpbrm]c2fg(1hdJeUZR6Q{SQBV S!rGC,LQrVp4| _,jҬ`E(o}2k':͈hXg}}|ip{ߟ}g>ۿ/BQB cT:^w'BhJ &\[r*\# ^z[<2W^^M{{탾ZU^Bz[m&͛J;Uʟ"Kj飢=ᄌ7f)8Ѡ`[ ,aT5y-DBp'Q鴛c"]l_&S?ѳ$`ʘ\ku[Eڝ~3,/W<<`y%+Vx™R(`8 [b%֖./_AB(ABA$@<'8\ ^dQ~Ɠb2 ?r^ b,(7DodPzk!!3ԕA l6kmB@`U"Vg>\)I(˲7P2dntxtU,E샶y34> x_'e&QRdmquA;cx>P<ep.Lng𥆐e9konjݪU&Q?Ss6X@eNjjFgWl+慐epЁ"3Gk9ⱕ.[x(s$r套 SkU @MP ĜqEk%ntk7<ˢnR\?ׇ„舞 9Ç(ð@)ψICxa r83/?FU@BJ}j6` JAilnݶk5gYvp}fs AT9ul0ܻxڕ̎lֽ7X) P>N ! e"_>M^cy%M#E[k5|F󹿎B=_(Ս?e^#uJVK1J_.UAc5i;jج'\,8lM7WLn |ym7DPKHUu?o?-^`ݎ$I@b^RU!25s>j JnQ^6b߰#el֮q9q[vnƑ{&Q^M) ^JEmzlKFgk?Q(ʣOxPmTŻsNl6*H"Δ pwtG-żMN篸|38GޭznՠxkG !bl{N$f$  ,!.orIX)C=]{ Z~\6Vtsb1`@Tra>` ,+$4 +jv#fn8ȭBZuOwF *lNBD&Ul?tdzh;e™ ?3^a)S,+̜[n Χ['{$d0jb8MkC 8 *bl8[<`}BG46%#γ| "ڪp5^:cy{چغ98ܗx܆Pm;(8aᾼ/sO>uɧ9x5jm4.Lsk1/;U_­J٭5k`ID NY$ 2'UGޕ74lU T!䵢hc'@Ķ 8} 4ٯoeغCJ\knжMFmd06I`5jd "㽔KZH5L(w5߯tR|hcaE-ŗFK.E`{0NmQ2kUQy>ʹom7i_|Gt4c8o~jW+% p`@͜uw?$i6؝Pn&cY' S嫷~?>}PFΞըH —CobWuep&ϘHbp̅@j qs6vݝqlSO?_|.ؘ`,_Emu}cg|7^>$5AeK}r-^k C"!>8dLb#LxO}uW* =Lf>Q6kH|D-1 nj_5V>`dC4xf+]e c zG%Y{,cCzo~喿 e"⥃MnOQz<=gϝ V0ƴ fob"1%zI$+IXuD3i` s\q!"VK2F lbv:[>Xwkc 4Eq3\Bh dBٹc06 RU.Y&X!~fdpV/ջۛ/j! Acst.'~OBl#Y%^$xjS=_և/O>28IDAT?AMxyq_}䓿Cy 6M;5-w UFJ'UqZns<@3vafpbqjZ?D>om" MIΏp;2FEkM_6}e*IIӖ5Z":8 Hŗ685N8ы:W Z5p /a=~_ n0g4ͣ3=1y{GyE`@İ61ɜĤ8ELӨ:؎HsYVM#߮?o^*2Ck-X_rPQ,w*j;ؽ޾!)S2՚$rH0=L$Fakpܑ4(nH Q9@̣PJk3]}!a@b+T%xEmw۠ G):O&J5\)Ialf3FTUt: ڴ`KǟF D:IPu*GX󖙞% `;9x1\87nD3sAz6;-όhxL$!M$B[ąA/âBTd%V$Eܠq+ d ǶNv֤7om cB(~?[ 6֊#s߽htMK} 11eً*l25ā|o2|/PLʤBLtLTuw!&K>zgs\ܳ:FcFgԕkIGlS~KE<޴K6ٴ0X"W^r $ 0])ڶLf貋˕Vba"1-+$6j J#bTo'Q-,: je( C? 78h`evo2 ~sk_f,ũBW;u9DS%] hb/ҲkOU(+G8;Z@ $"S 묢SGYeKnV6߈SsteU;x !ܥi$ǿmmYg[*6=N$ Rs!aeVfQKc?xU5vG)|l/AiU議u=4DbXL=DC4q]3-o?Z}&г"CGS!Y,ԥ3J,@q"7rg}ڳ qVl?'>XH$ Jb!Ί~/O|պ{=zb$U\yznڍ1%#EkBNz 7x/hhhXN8 zxo6L@f^Ze~DEJLx%yֱ[9=cMw~̜(h7%ȶ=!'yvg <-崶+u@~/߀z50lĄ魷r"1p\ei n.2;RMJ|5W\rA_-eP1ڀPU6XEK@0 %/R[PT߇J{TzQzlw&:EcVz1Pݘl⿢*}3MHq-)3d?iLxRU"1gYbj#u別@O•rCQd4* `tGgwv$ T`͏'>xpc~PL.<]wַ.]o]!ȮSa >=C;[n fm_Î|. lԫӳDzP'k-=rY'bәz\0mdVBI -~v7u܈(wzfdy^]Cg 锣Lf.bx2>@YOb!mF})e/G9D*55<)¡Did\#O8Iv8d;[eø A=2sչgXYA ftIa+3IPlm`=EiͶj7xUBhe}ܲ 7Z֛LM^/~No#͂+chFlsQg"٭yg C(2xO0*=E;AIŨL!OO|ju\ca $@&z(v۞&3^.C2~u>It`1U2K(:lDb$ ў_T" Uh|LJ L!ԗz9g-y$BXk]2Y}bpƜeƲcmkgJ[BDU\A>hJ`Qj8 d?050*3ܓ!ؠ޲^f{0Zߚδ^Moy~~ͲT lpPeya5 eR" lmM7^`Md9ZڜMz(ae1*`ד;0Ci7Ͷ{AB*&2k\|y<0U2dg17HGiĵuI43*Z-wyfOW?T6: }}9J)4=f6㨨P¸ ƛ/ZEs.HÇ޾6I,͢%=11b-b$+Ayd3XlQ[c<u6hPYo Ӿsr iIZMdf#\s``i ]x>jDWWz-{WY)rq~]vq]̲lĬ6~2LnoZCSśJWT1{@ hbADb(Tuv};fb#:JZK8}ŗl^Xk #(A|cU=\&P(Cqw$  | 7p@YQ@ Dlk7f6Yf0No~߲3%I@۲xnY1ģ*%"v%K]'-ڶ&o lzT`dI-47|駢h7e{皚mUJcw 'X.H,V* _XqW+̦֓&)M^aU8]v[g\ yxU1i[+qPb֨E3 -HTQ 93  V6ߑѶl93:@YCCS[d9a _"wD*S?P_Z1JpAaem{Ck9j*/J3@tP%Hn;W,#\3O>= ?|͌VV[fS2 Mox4;Y q)`P9YFrQJYՂNBz?5!3ۭoxh D^~o2Bo°{eŧkJ6ԕkJj`lf 2r.3BYu %5VgY"19ֲl׾ӽ+*mZ+Sڳ 86ʍ>hqyABe#^ Ta[[{N|/2-BTQE"n} Ŋ N%0o[ h`L '@ d줭=Fm;mxe+< %:Vk1 BZe'+d9L"3ɦ`Jk.{ڬފ*d+~꡾iʬ*)өq~ kRUXH꫞ӣ 4ϒNjk:5+KHA0xlExu6fh+:T^~ڙ4x,ԒX d>^-u !HՁ$c@LΗ}󟆔"5z(HB|K/PTjP<5p*J{S}[ `_RBF$5bLl;؁e`-f#1PQMV 9 kQa1'8@;x,̓0Nɪ{./UR +sLJp?5U;4k^~fsZFK/}! :,iJ@ Ƚ}FTc """*ax=&~ΆPy cCo.8P5P ^_eA|Y?OE+rLˉjBr1dZt^\&'f(d3$Zqљ9)g$oP]-9fr J 4bu^0DD!8tak#(+[eZC1ӹAmF %Ubb-@L"RÎ\t %4/E$~YeY25/> P}#R[ml1~!ˀrg>&apf65,I3A{n[H$"J> ;S7\Lo @m4ۣOz/2t֕OwT,a-IbƁ{1! (awDQdf]DsA8lͦ >t}Zo `Msʤ`}Kq*Q_+>6hZgGO`U(>E6˜wDUE3A9ېgIEӠ k~x((s1Y<87;j[lVEq+P&< DgkzV/VH-{A3wnSDo7A%/Xxu!~ڢf%3"]W]x AChߗNk=]V5(\`~ϱ' }hE}6^y| "0U2B*!2_'k}cr#DϒDb X+۔EFײq[ƚ[#1;>SlA E^*E^2kG U6t(LžS=wkHQ (E_`@$UJ"P/R7E.urMǣl<@:jb pG"d,2:K;۲z>{XwY]k8%,}>cؕTNb[yQw>3bń#htbIBtphMa.-hp'{K.Fh@B[ȱVx1=dO"EۦՈΩW89\y9}1a6(ec _~ٰ9&CwVL AU5K~glI_fA|}Jb>ĭmZ~kH$@ñ(QuY;oeiuZg]2 um/l_~i!Qh` Pw!8C-0'7W [&Ch[o;790DHZ #sם"3 y"S4j7&"2Ѓ{UWbXuC pa0I|ZpRַ ȋѦElDa2_T믿C0) 83,EJ"Sb5'9((t]pT1Xo5=)ȷVGrF`T壢7qfc.#UUذ2ZM{ ii x8DYA 8|jF˭lF'~2?o~~ sٖNh8)B[cMh;FLĤ=j L}+>|"d2`Rѧ ?A%ЬG$VGll4 ˖Us-lnG G‹(3H}Z"?2WeD",ob&9V`8x;mrͿLYP/ٚ(R~g -AP6NZFɸ mޏҟs>46 Ԗw6=ngpNQn3!"LF@ecK=lIKZ5]]ݔ&+pTɌMB\fW7xM75q9@#\抑>'9QͶ[S2ދ1=4:.V䆞yO?4Lz>kg0ӟ_e!@T$x^F"1KLL33}+7M#Sp6x(*8{owwlpDÂJkY:G^ ak8{oLRU `.<Ϫ۾,! &Ae쪷Ẑ# GSD;BDDVj$Q;V$򥴥B 8GdtO;XcejQi 5K.uxmLHǺΕo)ǝ -2ʺ`чVm ֌(Ơ<8 E9ˬo8csV_Qh#c߮5\ fD]s?(NyH|X#vy]vqAQhJ^\;Zݭ?eXBG?|$e6>H\>7xaAl9A==0cLADbQSmDs[~]P=/r{E= e#3ԮJDy#jףoAc'\ (2YXzTmi@papg[oˌio8UuRi3=&C J[Դivtf+\EdE+U%WFw ԏk/]ekP~׽^:@15n'u#3?=tޕ`^{O}l5KJqG A҉Nޤ (Bw},*q̎@$ Aov\a@u^ λuց(jJ5 =c3fpIo7Sꇻ8$J^xġ;T3 f; xUci}of]>D*߰,W_~܏Aʯz c@B\/oB,1 ~UΗ]pn_F,B*Iz?\ dDEժ)7o[y 䝃5 ecj5sRH5Ɵ~G<+Duޑ1Bl`8'n?sϊ'Sfuf<]v&q}W p O %ae!U&xG[2#5w8jS\[]@@M:p"e+#;l gز4JH !s^|8ߍ!YF+~j.% &2lXlda~l@NJg|Y>t_J򟀏 KجȊӿ;P!1'{ޤ݆?@"1`V4oOs-5 f!KPHUP:޶%@nuN?xU `"Yf́-[#i"hZM3rZXU,Rhl U~1ɤ=Lk\tC?o S ԙ?3sttMs{ DWtD_-Q۵3DIgVCu|9[ _"5cW_̮DxLᶾ)LNE3O9dlPؔyh j_ }E50(@ M{% yg$=+CR.YvYD`s43>ߜ9+y$^S^yyЊwFR+Yf~ߟ}h-H(o)Y>u+i{[w#e鲭JPUneWgv"l0d[y*z97S>:\I)'A}0jx2?FsA,S\gSU.]s\їqah'13zBQ6ݳ"V4Qђ iSpaENA ,Ն5nE6gk 6St$  `b"c LJ/\J4 7"Qb wP-p3q *onRi64 _jՖUY<&W\ \V!LKY߄sAyxLP+ (ҁRo7| @d<9pVy, ;0)JڑO$~бB4jYR|2˯y .!e jޝHdp'zM[hWC!xscOLsA`_\fl|e邔޹΃HsgV&zΉSՉ a7dQzJr;FؖJNQ7\Sema koգm41$DXj&w-7HM/s}E^&9te"#~QO>r_nsWU!E$(V>Fn CTdY^zБGF Hrfbj (cG1w1_:ܴazzx !#qԓ p1a7ܠڐzID B bTh,?_PpCPbNjK}S\zC/lUԀ \#གྷpqfRv(%\X4\0rTy9IwYGHoG€yxn$} \N jJ<D"9D7iij`kfwܺ/NLzl-D_zbDdEQ0ǽoȂ%ghdU8zXhcA.Wzl#usu6gkۧ]Hџgob:k+Έi_ufy͖Dbbjr=W>n.I69]PS0jRr7OPYmKܚ@dOη޸۽~Æ ҕƚP:O߁]xY=K*a`^h&oN#S`}>Ĝ]eXfUI.o %"5*;IdbHʯ롭)~ (j4Hh <""q\4iX@9Oj?/ ш<]=Z?jo04 i.D+I=U?sbsҠJw䴔z&wP0NmX X96A\$n-xfc1ƺVKKzj).JH*[+hEM5)zR -[sQ(j_4L[DbF`Eo#S0iX&evϟ}鯼q Hݕ |h:뛁> ѲV̾f6NݥNjZu3* W@u@FQ˿_?pɒDb&Lо",=tǖn(OvA+='B!Ve ~'&Tgc`Bc1oӨzcϢqlAD!'? /B%f>;C> 85Lm@TO37 "L`U,Ld\dM}YEM\ 72'=cwo揨bk/:SNGTUs>̆fJw_hKČ+.'#!hkmBDbQHXR \wgpCH, h W>v=|{/?laf&vA(q fZj,ag53;òH3VzqWzN_36U\;:U,)o[`9VTƻ!9f?ͳ19@[ԿV$&}= C(LP5&(D97PK]ҵx辻^F\Yzc$w"ez=L$_-%F BQF}OÜTC>h4:]?]I{ I%T}9Aҳ[n7B]Y/>-ZJ4xnVѿNG3-@HJ$@$zGS<mԨimHYex2dҐ&M^Ύ$8@UD0&mZ7j3L j?&q gD5c"ǐ)q,hJ\ֳg[`x3No*$0:\0йܪѲ6&DºKuk Ll@b!NfvWǦFDb&iuБxl]k-,CD QL jc`hj zGk#S6)y_&*3m-:+PW 戞f~Mift4LO] 0A'Cvxvk0_QQ8H$z$3x%YT hVjvjz!ANh,o=Gz6+a;$_lDѢ[̿An¬ U01?o[Y2LsjPLV&/}Jpaqg])3fuI5pH܁&\3wX Rf$ Z]u?}epL.D$ 2*47\6k+2UebLBh:?K.8%ek2WP/J > jϣӹmP=%%DD1Feq߾.;E_bR+L?DbeĬBQT[U@!m?twQ. Bvvh^ctVk '/g?R[l q~rDb{!I`ӵ=yDK30D}_n햏=r ޻`iF?SM$f$s 51e m]FRXB9kp !H9'~m,!"kqAd튖DSȂW=nڌ/c`,[kTIA,;Db/^cK狆1, F !feK}׿q~,QbG^5 @] U:?+Ka;k`1s!ڤTlʒZ"DU*&Rtܽ,h ?Z{`ptTdYzn0 J 4;ٕ-˴|J_Tn%#xe_L6ʪ!ߓUEpslm+6INPD\cx4T Qa /~3#BAU? hkZ11H^9 0Yrw|#d}d6Æg:8ݦ-4ȝwUxR-32I㜄 w`/`4sΩs X;fC ! K"1KD'rMڬO `Ŋpeg]sT.g qr%akLnL> `6!aM6|{7`PzjހDb.H7^"C쫪se-r;wϝ2-=}6Xcl> <8W>uk*{($W Xa;?a52 FS?SH(Xy,ɵ<$ hQf|<b'fR7vƻy磵jlwV\tMIQ1s}#X:2zid~O?/5 %$XFSH K$zoHX %|Hgbu`bRUN0M9:4 @/$ K0xbAYzлl,yO$ArJ6j1=N!Hsg8ւj:IIWtMxx2O?O$1l9ywloūBc-"+#4/" PUQZvs;1)@!xbsecŧ>]se AՁJ$'  J~yǻ}`gzܼ͕Hr0́կd`dDU#biRKj <ϟxuy aؘx C+6^og^ ' +-HGrNT-df|poчd Tt4W.-0YĠv:g5 $(~Y2ג:2D jR:`V_!A_ !@f6"@"$ !&#E lJ ?; s&!2mjT9D;9/bd0Fr~.oa3TH$fQ:8 BQ)21G8"E)A$B0<zk<1>^͢JLɡkH:4`VY\&ㄥ㷷=(_y^]}:yu}ӟ*E,qJm5;\[4r`2Cgyvߵ`"QO >Δ?O$fUX4(va6fqlJry:z3z?fJS_Yʆs#Fi :kwbFb2ժ轞Tuw :,< )bx?c2NyfHDbAAhr;nᦛo"Ua3*_Eg)Phܰf{j v;}9ru2Usht_Ms4!"Vg,nCjI`>:s}wB2<=D"1Qz+H$; q \vy^|W0rR&< Haf?zuדhfHAu$NMxaMishc{侻߽, !g O DoVylA  ( bH˖_}}9 !%(*|&9d<RBч͙+iEI7aw%b}—6M@7cKǕęǾvsJGdn\*a{aY73u]huwJog$# XXph0Q a=}xw{ͷf3S@Jy*Y> _ /TMy9"pPLJ:aB7V  OV]U 0AD@\"0Z D "Y)"/ޕ2YNV66ȓaa($`"be08gm6G~8ݨTAĒ2_q8Wm))-M%OKJ0z7L$V#B!캋k aX}@S\GjsΖMtӥVNETDUBIdZN2ҔF.u3b66C uon:]0Q W;XI@"RP 21>'7bc @:{%3DTtJ׳xva6B#|?ҟ,cD$H$0-H$i@ a2JIp5~su7Z eLNf2n7xοc)X!*2/Hx˛x/n7mݥk|ns~H$ˤ\1k/5H26r^;>:{g6&i@(gC6cZ(2e#@PDd J,ZҐncj9'c"|0qTU,DJu~%?+MX[M{|_6kI8U{LcrP0-tCFˮ}驧 3ߐb;j$ڜCcr%@D\#/ >cw+ƈBι"e&U`՘$aFT  F)O_g:AY1dMM<yoe >OO%߉'YԄVYr+&p|i0ztB2BY̴'HoH$V)11V5)?oV@T=F捭7ya^{i 1B&UT=_ ?uf,PZ #m%]K{YA㺃f啠ZZpˍ ,2(_"6H$ I$L4PK6\]o~nO?[eϨR!O+Tm;IFz#@J6, BmoW_o6[n&#KDbn;YDO~U66 ->c?s;Ž ~kF GQDJd3Ìh)b"&­= Ltgʛ-.lhxZmen/9+/hJg=i=SmdVfa9t<3f1 %h,[lzioظ ''Սdy'1$KH%H"]r-<ܳi4""1Be`1 +W[[V@"f'ڶ ukBc fnۏu〵LLV.<47WcPnߚ?]J\@2m(imi;Q6ω3(x&M׾g @0IacLyO$BrDYejH-BFB߹/=s{9b~L "1E|_HYA"ꉵw!$HGk xIz}l$L[zf1,5n-6=͍~U!l{s=D" P")H&kE0\@G<}W_}{m?]Hfpvhptu QLŦ!v۽v7f"2D!H{,d`V$P T'Ϯ62DU(⬵.T!N;=vݩfII^I$$9nO'D A3U. 6oM?pׯo'CDJdʲܒr\P2;hWxFŪ^*K*zM<ֹ"2ܚ0 &* 93ICC\1[%sd0Z]2A QpJ@Рʜ5|x>F} kT~ᦛn,] }K :_ e !" hCѕ&y`1̥sy* HYBi+s 2/+5 o`c?-6   5y|̉Db,z Jt6=5) 6ȭWG\6 jtP'3nw-i778U&ēޯdef.DjgWo;\uL&!y 0 X (ɇ(eiԳzʁqNK+G7k(BX/~Gz5-.|pdx69ث{$yϦwkwYHb/.LХθ5Q֋ xqyoWk^dxrXjMf,gEeEQ2{jiYc}|m>8">1+!@ˠm$0y)Dwӟ;sQJKYA&mO[ RDEWt1L{73Q2 bR+aWbêl3jk {O8#kڔT AwB/-h:1}z͞$`I@byTeAp_dD5s/ ~B pz "2Vy:ݚv:auzB5[8쐃k0 / ~ }>fw0$ : l2U5lfKO׾ /zEP'T7c=\9L 1ϭ1U>x=ݟ'rs6U&/$"̽u= }>fw0$ zS]?T""QT!Oo?/" KBP0 Š=Crfy76 ->pN8nɚe!Bg6#"S8 NL^g;cf$ 1Uگ( VyAB`k ?={C=̵>eRj,WkEV];$`v9B1_P#?#N;&C&F_TVP]ݞ }>.cX]aT9Ri/ y^P7?ܩկ}QoPѧ"Pb09ÜY!NO<ḣ,(fe"7hr=?:1}]0$ z2,V7Mk(E{y+yg0pO><~0[09C9N1|}=C?nنP9wo]u3ga!DtB A)0&BxA JwxA*P#`"!A&V Tjq"Zv>ggeuaԙ>}ٽZ_k9ё.os_cEX1tJg}Dbv0WHer'""=3Kwٹ:R$vw("w7~UHhBS8q"uM?DM꯱Z}8㈯{u.,]9uo2U9w#{ k޻(P%VdIHD;@_}IYۺ?i; e }Qs·؈Z]Nb7;oEI&2focL/jMxQf8} }-ֹw<#+85ӍOv޹ h _Kqgi `EmmP_u[nw7y}Da%Km@FZZe{ }-l\"^+lmIÏ|ȑO䧎&j(2rOcgZ T'/w~;7~)DT=`%U00;mஞwsvpۥhԽ;[U5$jb`noj{+?cN| _ؑ#|+"zUUyUkuff m@;̗B]- 9҃n}ϡ˞//|FqF{3]Qz + s9WEWޚNCS[/{xۭK.5)Kc=[U߽+GD/Y}o75+M]e(k 朞:Y?g>AT&h7K-H.1~f[3,zMԍSy^wnuUDwûgqR i"`, s8Q:b "RW!wG?Cul63]nuq;ij!Uj]|e.?^x5U M|eQ^4fjf%>G2e}GDO˜Ի?e4vH`uJ;J/y=u1hCiDU{,)3c0VMfk۟}ݑk=S¢G1 R|]ec ׽/^q/?-w_EkKKO\E#Gj &C;߄Ɨ[S'{]ws=?яN8YکjբMD"6sa+ Pݼ>@(tne vl8 皪/N9\QU͂WE^+֛O`(bS91Q7nɘ]j]j;}jv~.}C]Zb(W-^J4}-ZwJ\4y@l;,ʦD(˺jT̜Iqoڱgyѣ_џ>Կ#Wř\nY: }n`s g/+T֩'E>pE/⢞TVPZӄzB!D ^:XwJ\4y@en,\[-ߞ$To>y'}/22DӲOxGUԷsUUyY*-K(zM$rkKUQUu/(k:ZMb.W:L8TUcݨsŬTnB]]7EE,&Ƣ,W5;v㯽nٗut_`b&f!}f/^b[^.z3DRV+sZXw8tgEĊboⵉ3'?xk|g9]WXtAD/" M̅xVDDTŜݱ> /964ff 'MEVU79/{=}}UW]yWo>wp*ND/.ZUw-:(h6|3 'XwJ\4y@#2 﫺vyJxo^%S;;ַ~v~}~_dMU5޹03"ntn} Tݰh;x7PzuUCh׼׽g>?ӿ7|"*MD9gff&Np-l^hp4p> M#-.oR;?p58o&]fpvTDBU,{DqNDw}}fvW{}>s؉?}N?cx8:i w&\[Ͽ[oz[[[Eoewq/P"r5u߶ ׄ "N]]~[,iw4Ec GW6"b;Tuzu]s"o(RD~G}My`v]DwvVQl=Η7,:_":=Ebl3U܄w*`RkЕh1E ߗmmm *UcѢeάCjWW"hs6ۇ;㙈Yݝ6k&b[6<,׊H@Wj碝,C)˲(,8Q^Ek{cjSU f1EC3y0* ?(1-viɸ!څ}$ݻzvFd4Vk-XfJKm"^WʩOh`[a1NgP1_݁Kn0WckMh섅 Igm'Q:`ڟJEWj lؘglj6sg~L6֞ `tڙ>Ð;NTpQ\ĭ& '@5Nw;s~grerp'Rus}#԰q PX+s6#P^q%@.02B2B2sY q!!Y9Xj.)AMj2B2B2n mұR0i>s # # # # # # #? ddd9y,5Z\/: # # #p֯S|1Y|ܱN9>>`$ , X1F`XjuMq<Ik`@F@F`Du0Ó- X1.:i`SqU!!ѪVfu͹2yڱJ5E6˔Z׻'Rې68`qy>Y:r 2ki ddd9FXkEc@ # # #P kg}z }|%KH ,td7|q<7[j/kϾiL}j 3zS # # # \@F@F@F;M?E_wYYXZ 9ưLo@nFcX&7 CH1,0![f}6HAZtNNsy٤"v2B2B2Qs&at4?Hk?a|N:ehݥ~aqGHsVǼxn^tze}ǃ:l O\F 1, V?AUBIDAT`$y& vS_2B2B2~Xi;`Ei OzXϦy\.6dmȺupֿ͡J9}H` # #[Ef[U<ѿLoUNJ˴sRJ'IXw?/X8`!0/ 7nhIhߟTWq!Km}S;u<qNN:5__nۮE[B2B2>Ոd5M:j >eqvp!!a֭[ Lka/˷U-ԾMsg}}D̄ddDljr[vZ sLD2B2B2B2B2B2Rf6ySb]s7f7 b.dd99ss6ujI|ddd} ѝC8iO!2B2B2R ykRԼ̺{j'5l??hOi.2B2B2>jFY7@F@F@F~i^T_8鱨:I1D"! yI( 9(]ϸ*L`My ckNygoֻ0?>>{s?ΣW=:{ n.~VS?g_O=~s:?>~tG?G3Kݯ _&vRk?} 5[_y?_|1?WCO?O_ҿw/_v?n~U, 7ڪ:}"k)oSzqK~"vm /JP@WHfHV؜Dͱ8Z?|?82~pd'O ?82f~pdM{U!<6\Dx# 2Vx YEIu}1p]U'2Vs'~RtJxN5ZJ0[%^򺡓󉁌K0[\lՇ^0σ'gOS:P3UdKXKؚρ嵻:6Y{:( X8gՃ;7:xpd(g5b%Oܹ ROFi>Lr~pdMZ8(J9&TՆKx<'wƬj0r:cj=zL1[ jF{D6$K(L bi Ny߬,_8fS4iuE9;7N@m «X>֯u;WHkex|45l(ꐅw.P+CHK7c VWEQ sͩni4L%em>].l19/>U?'냜ɯgS"7fa'"FؚSոO@2!%l* )r*FzV߇H75DŇ6.%4(sPdJdS\5f"6~qW\Y 5Q) :V5z`@Is/dZנYq=-1֚=H%v&ΞzyBgp?3b6r?Qf菆Fzn:QiIevQKC[ 7fxrьY u#Dj,k7s3 >e8 뼧wK)g6{(^8nBgj|"EA\UXE^hSA,8Wփ2Est h+ K8DZinS1EăgA0.5~gyDclB5j䥽?k gbp_Oܦ(Y\73W\y&~o]JN9ԟcFnk|9"Lq~>Jvl~2 12xsI9Y޶2&.j8eo,urZ/ૣ-H'7x)r=9e-^BgDI :lA1z݊:VGET,{(qTɖePB؝d44-6n'<ʱ)I!9l yɘd oݺu!^˄8>~Zu xqmĩ}3 !ډ)U $W=J_!g!r1 $6z|~QU.3ۗWhL }}-i-xspVUN# 'fl:~3a0a:pH'YZ~Ppr Ă'}1u7o-Qn`8$lv_&P^3L {wuvMmc&gsK@Avws/!AaJI?YF$QIM_wFxD %c{a ә JMLYQWa/WV(adzokڛDX|x78IY۴R S.ҁNujv4" |vR1y.(wوwΓVpgEG^u[Ẕ/9ފʤJOd* [?+&6>!!a\Hɏ]N9$gca5F #| 4EX>Ɲ/Xہ薊S6$Wt̎plX9{r^7e+w*Bls gUe! gy޷u@9+T^0-@_޿-ſIM]K6;? E n9\k I"N];ٯoEԫ*~jw;G.U  4(:[7sҗ2>$ɹ?7.,FIJ[aaMfJaMyI\ߞeC w? 5qb5VIdn0JrG8yVdy]I>Ɣ,Q]8]a[4|R^s,MrJ|{y3 'h3Wm, W w9O$!e4MvИ46q1 ]KpMGwsFeGY>d0ŰπJXצ&Y>e2_!)[@b;0j?5B'c()yu!aDW'RɣK^&P=pnHlH))S\%M?YJvx"vF,QZDw S@]xZ{mXjfG{`J8'vqRG4v5n;^A$5UPӀ&^9ڬ$$qb ںH0$.Io)!&.,~Cb*Fax]Nl8X",ê,jDAOVqcQmh){?7I, *xy~^.)%r.vUW-jnЄWݕetxԊ3 ")xqnO؋(_#8U㈩H谅J^l8<"^ xO]:tvUF%&eleNsR?ҼmF{u;02ȅT֭[`@ܨ=k~4vؔKdUξj}n-> 儼`ٴ BͤӶoi>J.?:lH&mcB7 27|cO|ד36?Bo Q lR:\b`/Z"ig," FR?TI4=#S-tl!TfݖF@О`c% lo U ws'B1 A= kb~U>3'ҽE4G-;:`D!,360W*&P аͺJB>bM/A^Ξzl%VwYZA!(ʆAVӈTHn͉ qVdnFR^-^?s4ex2Iw25!/WJtZGv;j?׽Bńpx=wP_ P_ WR߫X|GtZMIoLt|᫮¤۪XlE{倜)GAY .Bەwf|b JIp$ᑉtNϛmLq=Tx;snFPwJl"謉iͽa GMMt׳go2H&r--d۠0#:xŒ5VN1|  }sq}WEPhr8r!i;z:jLVOG7g=_Hz?{=˅訚P\OOޱ6V,ܔPS(h3` iץ!' GU\X{ɒrl5VI'oOaO0r|a蝿~_ESոORr+87v%hVZDHW/d@9_bmmge^[mef A6.O'gUAzKfS tUSp+/4S#W.אSÞ|]o=Yn%N^()R|^W*YR}b~:ydD<%f.=!?ݖՀ2& Ub{Np` `|f-1=HgDUdK(xg%ҡKf G!3FG30 8/]*TvRC̒$ܟ\a 27e"ڨiqDU5RPڧˉw-#Qdz^|Gov ЄK{X_\[Q@9bjhK(J9\f-iX" X= B Znzj&-T/ߍFkoA8?$C XYReej^9$DI$I;zEm:]Q$GvFv9VF +U%y?wvB6nwO -yr9H|Qwz_<[7(2C &g߷;Q·ό'pfr Q$K&>ٲQL*TLi[Te=ג\nC|gn.`]0ṈC?k9⬖ yxsոUqhke15jD?}a:_4zobܨ"=jOVJeeMߟN;$ޣb+S&[p&e *I#> K˓V] gmVIe;$cH5VP'bq1 '5=])?7&e:]tp6S)A#n]8$R)L,ٗOUd͆hPx#|6^6&?MzgE6=_`u` ~0,zl8UnIt:u-hqή[6-I,8I%Õn9YCyx(I$`:ѨcwH`0sk._] RDبMZC{LUDL_) (4 C"8wLuDL|b`$\ɚ| 3l %NF\wU)!YYrOڮK3*^I0ʛV- Le H6]KzD^kċJ&e 0ׂ);EPPQMRP?Sum-$c \!j,LB/ /ffÅn-tkȦU_NP| ala֌0Çsױu}[TR6A0\=,xm\z %m>^r$j@t:ƚʄl+'[xm 0uG(Fc,uz /͎ZuX0E?(u걊AM۹8cI!6Bu6D!0^~OhdDɥp>$.='LuZ__o# Ljs v猞' K6jK%q@ w(u{:zn/q]4C;SCAjp恀_CѤ7Zv/B' N%m7ѩ9>0NDdRGP >x)($"CC{ -6hu?"Oj 0xO^ha 鄳׽#%ެdu7;$ɖj mPUͩmjn*qŮ)·Mԣc4&i,XƯ46ޜkpɱhxHj(mY72fp` u B&gʤ9"Q!HD,,y؄~# XBۣo|}{>)zZ OGW$nNږbhigHLG| 5iłp<,1R`GvK0ue~ ɳ3$VL~3ްQ| jpm^+&oREa]joe/?R"fVRP68׹2D_R}}#['bVm%$Nq۹y{}&ڙCVUע&c%jD9.Mq*ac#QStC,?oSF[>4v{ìdY6P7ԿQq'SV;S#'DM/ fdn~n'ra0D6h>9Ÿ4aTHU rFlGE< >XhΉ]L>W!\,}I|e ЭP=٘}B=fZ͸S0ric][~FBn0?yœ3B@KT<)Jn,PtVk1+VGF0&눀tgfUA=/4œb5%ӷ`x "rpng9ˤI_$oנ-htX-vJAhObӺR3hP;1BUff![{-@S;؞K >6l{IQue[cI9xShw-6"=: w#0=;ř+ҫ)ڹu7c&F&lo2Şnz#n-:]L|5!ك9k7FeP3ZYYq }!{[wژ@I*Mo Nb843&tz OcYtpɹƿ#ݫ47* TЦ*մ>^y٪̖s BVBnjm}UZc/eS}=]nł  #` T;Ys"J ׏mJ"| zHIōJw ?4SGTfxH^ɢwBcYAG#huaC6u.,{ LJ>9[Dl>Hg.5n#dP1"P)+n^X4Z! HXG[st#\g f NpGVX !pK׎ƹ1Iy|6> }*ORF0 iop:Y0=NOA=b)UIi4 KEd5,[tQ5İ g&TF Za+t|/z7(Q Ur 5mU٧ƓMBD!~dbg{:wbt?.3|Էm3:%ָ4?7˸+ KyJ0}A$(]d71Gaz+5r_V/,_Z,;nKqv{~#?#}Ȃ'W mHi{(BiBpWBXTq L$LbPty?G.$YS 6Ix_ω//38TsN8aPOaSylTqLrX0U6^6ؗ!K ܾu]HKz#Y1BoAv#0ܨjfer?q#}XnFE5 ɩMjb^s'L)*z|!IU"سb3Lұęzǻ؃z}d]G"|MJo~r"o15y[M=b') ?%!OG}4M\xq˟HerOUjr9 m'O>A"e<d褌ZjB'+>H\}Zw0Ԁ+=<m[l ڽлI<}E/@ܻY[kr@MݒkEMW芙Z #r8{=V1JyJ7w 79r~d *A]É:x?xyyM4R)+< ,Xږ(X5i{ uUn9j4zB`wA70*0_)-T8.O9y-g v>J̿ZA ~őA0C/z˙iݩB ;PvM MBvWG ; /[w|/F3c F*4?ȅmt /xZ=:KI87f>Ky19Z q\̇$ t/hlJLu=*Gn>?i'.̒ q'0WDZ,#+Z*$/n1I>yk$wK&bOb߼BG{m m+* ,g! |ʈINu%`ނ)]6|rbT<6PȔӡ0|B jЏ"M&m7eJݿrV\6?ՀXa/Cj/tr՘eFNΩ8b)lXsaԘFw;f :xFAeJr7h'ʜXNyoҌ8U? 8sģcB y)Vo67 5E=Za,&qޮ-V cʯkع[ e4mDYd~ƩUgCv,}< ?`NͰۄ?NX"[K±qTVȬ$tެxW. ^7;b(B)cHrpGL1{+0o$U4s˥je#)83}ž ^OpұO&8)+ⶋNYwQ D)lOۿEHHiXשQ Y!x9(=gԁcILˠ fTmOll.Por\kKkA;vÁ̱4U( +;'^EUptSyE}nVOҴzI/ PuAn11Ia *.wEPn|piaQ]HEa:/7V5nfE-S ڔnpt ,0OS^&8=LR,L 'm8,^0?>󝶧ٛEG?4ԒHϹ KUN'h.kwx3|8(c|U6x$֑z ̀awNBi`ݟ3 2p&`qo\h`[j2K px6 t{nX|1>r,&5 ٲ\$@" ;.Bz,׵nqT#,OߣmnJZ  NUlrS> pB$|Uf~I"<"+'gB|in$/q@q4_ pC~YT>Qx"&fn86ޮ5xjlliTI?L%=M ΂1WxNч\CxK o5hSeH ތh_qV@*;| gD[tN@p{{0R +K)^l:i`O%v4@8t_j )_:g١wэP/.MMܼ-.Q3˔ ;hdd@H^^ m:vMÏ%-l ϲK* 1`In&mk745x rLf61d0ɸO64^7X$PkkiJXG׀FAy=:VANV8 ޗǎBQiUN5)ƅ&ВbgZM[4|\`5{ s" UӉ < TeHnb; ~X-K\V*(1vpX Ή{i $>;'BT +#* [*ӵ *rY_zs73Hzp-ZQ1XUߊ ߟDaov.}pۆ0^+JMd9*~eӬPKLE)"/hX'8ژZbhF0~56zRx=p1U%våϯ]^m[7 ӛw3cH7қٴu6XwU5;\}tf2 }ع`<7#6 ?ukek]]Tww̔MͯPNrrĒUJRQrmTo71<_W#V@p&?"vv}` 7Q+ݯW!Þ-۟*BGir DlO?.sR$4މ ]H~lSFOUNμ܇d܍d CRzn|%m&O.״0u~CWkCv*T*ы̐3ћ'oK6*|liXݸѳT,Sj6`Vψ i*ųթ/DLfLؗd6YAL !?Qx7o2iGm_`aٕVijbdڂDЍ>#fH$Ӄ6>(ڂ{4?ΪkrD"{A=`kt!̯ksE-?AjEd;EH" 5$JȪc؈f@(`9{o.=V4^:%MD2lO u,/)dSX/aI*0g9n?(e~ұj<[G;kl%(ldñb¹ k z&;߻ȟ՟B7=iCi<%œce" i2.aFK 1lx~\]'ojk bO.(0o ,]eף*́E<4-CWj/`;l獽SScD%aDzsѱ34#:W!O *N|k! :>#( +t_*n?Q޴.ƭ%2aZ8 .\2!QߊU ˏf$Ar#9na~{%:jM~ M@y! '|cIEA+ ‡%0=k-pܿʎACE%B}Pc,~2  k6##S4LUzu$Qɢq q^wcGj-\bT==A4 ܣH|֮>P)ۏ yS?5FKLo{<`,ķvv!`jQQOy2sx l(w Yjw^2!usd]"ب] B, v^й(2B2b( .ڮOo-aJ,vFo Uv|L4,aVRXUm4`E Da5v-ןҳO=dWZsk/1]&l@fe6 4ٲ_>o;ogi>_5Ekvб->8kN 9|"ܟq_ S h"^, 3hܦ)-B;,$JV8-Z z~ޯIUxUjfj2Q4&Yp[52`-s"jBYr`\95q޺8f6]!]n[ 5’b&!!WSv O 8̬CHPeKlK8` 8 +Ͷ"Q< k9ڊ"<|s8@L٠M47C;bf17Džű9tD9%eAo()٘ib(z7z%r\0'jyMJ¦Tz* [Tx$<Ȣ]b~~^*7EGWYsl-vI(!qqb h>>J'0 0a'YΥǃafĂpBYY$J7K4ddÍ^b;զwU7(#+⮒r- xg-Ӭu_(|sp;L⿉[`03 )V|EIPp{{R l}b'T(]ĭ4XFeasC;M4Wj=>6v i Bc{( Q(J$'Sȫ9.|a"\bS ƥ[|W{P4QObzT2p>~/ty̘!BA3RTN;(cK$W_Ld ؊3EOr3CN?:+ߜ}ꐵWZ7OcNKym}C5!IFY%wuOHߐqa3%`Ձg$IL YM%e@pҹZhEr Gže]wYEQJ3vFI۫9 ~/OkΒ~R}G}S?>cQ5f*,ً&@QT<!89K( X =!ok0؄ѢDc@lE UuR}'UL13azԉXcǗ ~PGJhL<]J{"'uSRԩw$D>W9m$KkkJЂ &[,}}m>)+k[AB,mET"cRHVubW=[ B 'y[$;F)G j*D+/j >@L J×>Iփwz8@i`nNWYǠ?\LMYQr[ds?P: ESuAvwmyykNhc,Jz z@Eŏ&(5?iys?mYoK$=XII}unOiBW_KqgH3Ҟݤ+M-910߆eڳ ސt},l\CjNnr疫G(k洏8R Kvjӕ֩qipيnLQ~v}*ͤQ&η(* N_>&#PW6lҾ+)(n6l$\<ۘH~{"$6. A4\:N*|)̹ 'Ұ/^PռW ̮Q3A #< "T먍]4o+#sr>ݻu)C]Xڥ4O Gmߢ sP,c͢0?0w A:y\ij}VN b8 2? ]LdH~_E+ȴ?w/91R]`H6U0=s 2n;"jGl\Ŏ__V]QPfwHas7}!ĞJ,gNGrfOrq!T$DzAJT)FJf85RS(zϕ\RgYęHmV@d">?hJc q<;_c_No עo"5Ilג}#ŜNF `;<Ut5PW\֦=R!r-I aE|ќ7"K2!t&bvyVt6*NjlvQXnƥ{:'P*^ecLa+.g'ꞇ4VWT&Lɍ>}'x!% 1@b ޻|nIoH9&Hf"a /R|iL=\]9} kJ/ö"g@]l0I,>+5=#TK^(Of*e~<-,#ư 9)2]] @$'/#%0O2-$殉@c.l1n̗E5BEB#Vbif 5Åq@).p?òLC@'U-X HgU]O!ULff16;b^j`XePzMO8~@XkE,WZ8QTtyjϝx J_`v_f[\-oRq'jÙ1 UL\a[n`FdXۥ^B"oxm#׊ yP4r %|wbspE>z{VhL9vLX'mڀN: sK.f@(Sah":$.kesMvWvxHP gsfD@`Z{""]?g*Boh*jӥBZ\zMc){Rbc|u|{^;~Nis`2Y8s?8w^^]=C8ML-(q6huwk2b$T8 ϋ1~;G-tʻ/~~@(%7,ھC9,֛F5~*n$~RtOaYMz*IiΥIE@QBhux,]+‘1X}l"1 w&E2F7NNƂ17txYQ*ie!1&.IbgmKLFqMvko7B}Mzzԭ-\XpWPkwfJhJQNp3N}j xk"kPP iʡB_](ux5_ ,p/p5<8<xy[<,Z-\0Obˡ4c <ze`r@:`ą]K+Q0IZdc7}>E2MARg@[7BKA=CZuPpO@đNI9$Ebܝ-OAl/D۩U!MS$u|?(T5-;+~+1y]s4 rOW ~]ppS_H+,20dh[GHdTAG"Z5q'rCK#l&4{ Ss˴#9\a%Ma*ælw`\`iîg ?Z4EEkr`k{ctݠj#; R8MF QK&9SȤs\Ab {\+t<)kv)ݩ626zc{T& @#In գڲӿnZv ?Eińիbtf_$)+WV-$1ZWXG*|!T" O[h6rXW"gg؁u7>bS7<N':R:2Rr}asgHC:o;Ly%Z8 .wT;4|Q.jy]yCO)fȯlVm22`גw1Vs3fo7[kyBd=Yr@ dc6 D-f%eNejri=<fT1K93)TͷYX5Jmf6ӂ cWV6e}11cXTNVA,ȳv6uE.Z 4+neDz/0g,\c5.Ja߅ZN%r{4O9Ne&*?uɊ/ҭs(`׽%..V)X:nqe7?bĔOyDF8yv p܍ AqJ.qS@w~/5`]{H ظzVZYXM\JqQ!JrG7#n(@a*qyiYʼbYr+Yg(_5!)ptRgJT8-ƝؾyJw!Lh'eH*N>d ~DrM\<>wJ +X/q^(F;QAqY'ם9}L#|ħ.sWiB hbuT ںl`uIYԚ]zq;@a@@l `aH۫9~؜ ]D4sHcb4ڡ9PTr; ,Oxϥ麬C'c&Gf*FňI<SMȿ!̝ Xer+KrՈ8xf= Y _L[KGE`P/ ˍ[wNo'9ByFFZ6H<z2?avxb&j;_{D:{VB|S,J:Q0uj ЦSJ f"Q3tX^q J@fKe^xrνK:5_Q%b$K6D+gJ[Wu[[`; ^Nҍ(Lhь6zUpgǑ .nSM<0ᷥy esp2gYˮΌś>Oؽ6 |K^XEPA0OӯoAw$|zCvf-ƺjFvR1%SbLaYZX|v z#qxì,7q/k'j>Хe8:d)uGaTIjBEeF  ԫ mwUV6Ga{pbm׬n*+Z]BMz&sdvJ\|Ƿ3-}0f|Vwێ^ϋ+e̯͎!~JZ63q߄ L*ɹ^5=x7P{dwuQ y5In,*|•upVUlځ5 v+sRDB( ѫ$õ-|3`1Ji]tN%m1B@Mܰ )oYk+bJh ]We;XQ˭ٸk[cI-D݇vaJ2' e?jޭVNY(Gpr 6Ac%)%yZei[h3GOm ]6 A6gHZ+<:L? +@0q{}S,4O쌴2!MvY Cʼn";ul*ãZQߥ޷&0FcB8K\|?-؈LO-9+1g14}t@15z|2hK͑Rc|*{1ZiZNևULdl)L6@[TI~ȸmT: u%PsKAf2 "E\wgqq1gc7.!zah\#P)N1JnT_`B)Ek<.+d{X$;61\U\F0 f*=UL+U̿(ϡ8;}M_iQI%9}ꋮoC&,Hl|2X+;'py]𲃁Jy"CJ|G: (E_Ȋ@dLEBlg6+nmWXo_}m ,r;IP..t[e>{v~9T%J~tr&^ KCu-ȇPKAw 2l7Fm7ta98u5ݹ=͌7ΫXeNMmX`j::h2jH{] 2ql La3*4[Gi2+hWb9j:c90ߪ]G x}BLm&q:;[MPߊI vÏqX̴c1;Y*iyZgE~.]蓗}*IJVUu.N%~i* q޴:d63]1ڥ@FeHaz~pϦdTjdeuKW&ʶ ts1;-(%B uUCk},3F_$$qSK9p'5C[ _ݟ?@gZ)+l6g0<{<(lZ9s2؍BGOnGFپq2/i}_|)-[dDZնhsa / M|3^aA ӟvW6y5MsvJr85 UՒҝxAًPdly*bNZٗ0ĨӨɀO;;"ՕDjyPڤ1Y;n~Yq="DgwoY#&o?[`uߌct e)Ew0f yNauș0S7"{(uF_85 -KC_WOva1SА (Q~{*c&Ba"7Cn=u 6h5^OS9"\N*l?,;Aa1m޹ Nb9vǙm|8trA+7,)vfXvFDCXz9 %r۴W(#6h,>;HM0YSEu8:-7r}]xF;dޡR)[61Pج,at eԵKb9~'{9d ЙOy*ɤvV4sz)X= %/G H肎$C~ Ig۠ A1ws{=jc 9`$S|ɒ+v_{#(}^;\OrR=e$_XG~aWU Lma;C05y0D- w{ ʥxP`A!ܤ.ڏ)@!nɽḥb.>6@w9QQ >Cuj h%)TN'k-M}ˑ6gC@G,u]SP_i;&\7Z`gfi2-e(AQ in.QVؤ0XIrsvgS?®$~qR c+6~&ճ5r3A?\<{:Flw{ *,)qSdMQsf39וV;nh)|zogD`U>mn<'qi!T*_Mmhg*hYлCYą \ɉc+=9@f*'[[DNɵٟgGk"+E#;8κK֨`ս;v;2 &UFHbOI(w -}V:xe#r外Ĩ!T+m@L:/)uWmG]IJi+1ݒaan;]bZ@w hw'0eUdDXq~=$uoߤ߯۟I^nd|N.I5ЕPx(g;CzGv{ň2v{WJՖpÊ[L8; tY=XesH`L_S$j <g~s)OdET;`5Rh IS/*|4Ѧ}*{/A/[&싡Ӫ d"'wCG)WK&^5.%2pʳP䬀҉*{ȶҕ'd`^LxCFs.RCI*#{*1U' G##/ЕX:/1#>ߵG@|43hU25FajFUTcP%ᒎ ^@ o!f5.*B6/g7"P{GҊ\-9Pg}sp{9vQcܗ9IJq/MOfҚCR8պkD@z(lb3V_'4wfu2B#~Ց(#%<=Z$NͶumn :nLy,tj9{6+|{DZ.[ogz{GФ̆y&Dgf{U-&?CHu>cuD߇p"{%C%xJ-4bPD^] I/ҍ,p: ]ʰ6MrX$؞~t9Mv`UU05kԩW&Zvׄ"6zԀټ14rlXl' 4g;!yK4=<^!JaeԊ|Žw*GD5p^v14ja32hS˳19an\  < bYME}TPHc1G$ZݬJ?{^R;c}lȂ"Q eh>] 'Ջg۳<$YCtk0Bq<X3 1Q`k‹<ȎVdsԼ-Iݴ!\z~Ew ZfTeZжݪ]a551OSkѭ.)Iԋe:!Ct+O{N,T aғ4 &]9E &MlU%!|cWұѢu%{ᛕDB\Ns܍J(/>PiS{v jF* &tQ^I>t;f 6Lrְ辕gU#Kvˤ|]侙wb_ ݃vJj t( BNr#B7Tʖ:4P`DPy7-qi%A-H5XHչKLR5դ@gxsh{CВ%@K3tFGxmMssٺVbO9Gf>yOKzdʕjax)9#4!ݺ~ R!*ݎbtFv߮B;vӑCF$oXFswJ2չr=I{d${[1x6CdÚR #qb2JS\eB߇Jg\Zv$SVPF5}yg'a&x)͜:auWRenڙ7V^~Opp\pU~Z <.7%ʁīC=&O]PzzK-@l4F07BxϜ} M0{=p $,tfl$?tA026 Pt}ZsyS,6M-Xi=FVrgۉViUƺax]̘>[cMw3vbk?}AtieT[Y(˃kB7X,l%h1 ,^sBr(v4 1O.L2h0g(Иn%/Ϭs#g׋ȗM+NG҇~2 !#nOv4!#ȣ![qhce,Ԭ=+Q颛J8g=!0 ^j:QBߴ #>1%:v];xTpSi< C' #.y"_2z-ʖlpѨ2==]աප -a./'OD~KuK抄#A%+&{AӸq_G@mC_ gm݃Q̆E\FP^?eyﵑUMBBM@8& C n/}:#H/=J䴻@6ÖL`w d^?aԢ/B/n–ck4JTd<}(K6 ' lXSK921":XZ)5$S[-' q la64LT3,X "$2TpBpIN/ uBH^x0j_rψj4cl ӷPyc_Ilr[/V#VpN9v5Y$ 7B@Y8z{HIex쁉>޼{$$_.{iF,@Q' i`Jr)Xu5-!R,#Őy|NB_Md} ǵh{.Srb!.گ_K~u~- lx}D[E$<ދǤă|p[XzPmheۄ隬ҧ8n%zJH0G^b{]&&O湭d}^%:qXYo}ȑIBu Nw=$2˘:DSuF&:K{ͣZFz$?^Ty=5'./ZAGiHS#QsчA>E[՚E컧i$XgE$ZrCdwӔ 9*}f' gF44 4ę$zpLјҥ9 ֖(p˝c!-F9u[QE@aV1vz[ݾ[6-Wk#(=Ru#mO@o4eNShݢ| lY"Ӝ%4X/e큎b>x!Ld$rNObN(?E,5|)ŸKBWB ~rr}ЂP٣:B;'Ҭ꼦 !<_ +B7 F8:I9.Y !3%n JgGYضmzsģR-"TNdt(no $j9"A5#:Ѥ@k{E4.>:B)u!M)Bn+nzs~Y2 KҐcgQKt[I Ub͢ %fF$]RG9/,$*_3Ux=Rq;OVqD +]ptʥrl1C.$ϕ3"xa֡`&0giÅdjs?λ %&gB^$g#Nm7>Հ3Iu sqW;(JR n1'r q0 !0nfڲ9كzs2!*;P؀S~ CmK^FEb5EU*W *V׌$]~yth&39kfଣ"#Cq!Њ@ݩ~n_׏\;iX.ɀ$ɠo[bOT\[y|d' T&/"">kD"VH">l"6k%_ *-[ayzk'4Lśx꯯ՊOh(ӔJю_ߞOc)Hsb lN,&hp@!Js-/`j(F0e/}3QυPW!Ѽ}ùٷ7~*8hK^slf@:C!Z^^AiðBBZ9] 7gkC^Ǐgkop6=Ķ?vS[QG0SYHe:jLX?3DЀ=d7 3ā+j?b)0nQp[=OE5-m.j3,ԈDIٚm}%_`z^Ә^m{{HɦUƫnZq1d`H6hrRqy Ö gм׵k•cHbmO1 n0ox~ f&OTj8n/G;OF7ScGl<@ύsuov}( @6?/oپYڏ`/D9.sDAwI!߄۸nPp]h.Og54WRrdzz2@oj$җea1lj=-}Rْ[M8=׫jWpdnlљR Ld$s;/XeBmKr*@7qOSLK=.#| z+(+gnT]v*\fvV'7#׀@@ [ߌ p\'{-@tF?>V/q#`&|ѽP7:@R}1[3\/FDj~`1)Er^XIHJJS4 .z[pc+`B .S T ʵ{>>!DP]OFxs7Jӗj(6ǫ h @[) "cޙob?F`0]gæeT f-> zqh a, :5 m/hS#8gr06]7~g?k~˜@R-d~ p 4-!u~-A%-,jiCM>Bd{"}zSĉkWwLvEXPYA.LZ;Rb>*\wt`3JfvYe n;Ju3g 6=5{g]Ⱥ=rp٢6ηrnS%:k+J-e.% Tw ՀTm7`rF|αa3 [Zk$PbBj΀6M/R#w %OKT)"5KyЌ1h{)"TѸ-S>{ͬBܸ;t:#[ +GC~a꿧-E/*VʞGb샛gOpDe\H},WSN#\9̛D^`n~ qmsEV*.G_-)k>C//ܬf ӯYxad9/Vy`}]*mqϨytnOܵ}c Cp1f {,q@Ff؂A(C8bId}˰q Kijwf3j܌ɌVW0OEUiһiȇQ}+,ltn[rՕ%t{6YKR'*~qg'GN 8%D`QU}Aq ?z%V +tBkk܋VhSd 1HVLB|Zr4LkMK4Rl˩}R4\)21Hq [N%A-E os4䟇I"$?k/<;Uu$]?(c˖֦CJ,\^&э=Z8 ڛUv'>z ?QUOBUOʈ3[tw9\+8/*T-Ҭ72U?3[ɕr8=G{`v"*<J^:`|Z*F7@w*X)r@|(.bBݢ+/612pnb&RךtLʤ3@k1nv*Nc<\L( 0QA!{_}E|M0\L{`^|jϬ#+ oȣL1Y99O[vmx젘@ȁɔLaLOف\|mtdwL6"S,cc09YǀsI%gU[+ۏnCzb`_&{\ČX`QTۢ>O U579d-fVK sEJFl´ "k!V/gx#W7}z)ja5.+wrQvF% 8F?/5$ې岾CݨxG Ԭ~bpHOI Đ@DYs?2*g91geoyDTW*q89.(8OY]ϕL'jwy*rJ&ZVٯ7"_Qh%JO_ыEaKؤlŞ߃@1.*. r烽(.VRɟBGӇ:۵9f-Js{,_W=÷@szcKsEkBI Un+]w\ =C ЛCi ઑ@ .Ƕ:LmBoTY{Z>V1pVKv {)PJj:נҽJ3Iv=kN!gyAMc04c vcl^z '緟7֭1D.WǫuzC2I)ԓz// ](zk"|&[½ GC( ~GrKh꽟?O"m^^],Lby6 Tldkqh`lSjaukp0mk63t58U=WZ:M5rQ6GsN/?<ݛr(C2Rl`?,e$JU+r =t>'9hr`mjR!4Iьwk5{IbP0CX1+Lo9p !@8jCwk',`uuawC}%4_+D;Qj3v.|ÆZtAlj 2n?08/k2ve 0![,-i52 & _*ŏKb6u kYV`# _ַRtQ|k9m9 xdR4'#bC%Ȃb" mAѶ%訰[`  zި+RC:Ln?zf k4ajXrQ񰊮>2̪{VM_y^^2 i)YX +^Hy[p ,  X+H'=~(Abfw%h?oy*0–uM#2ҷ"&ev2td¹F#7i&1;2D8S<'apz.d<cvRL%D;bKQQPEY41Q=AKN7j}ws: 3 4v/ kA'$ﲒ!>T䝢W%ϤPaJ3pr g9F9gp6FX۩-wqINa?:|Źd%!):qp:H' HWҶeɂPI?q5Vh8S8")NA /D1u/n.CMhTxWBdgrHԤ\r*GN m[5Ix<UEČ b ,"_&vAFcA⢩GaT5>d3B1ă6NM`H_6Jz̲p˧V'->Gj,wAXrߴkxP3S»1p}*n_1ݷv8MA?R("[Z>c;ML@ C@M2-@m׭.z`ND<wWlA3ח-30',`bEIQ^|-F'/2u]e/XoПphHe_I820 /JouP ک!=.Z)3NnNlYhN4VעC. P ܸx,+Bf5.jI(+|l?to$$b!o |M~_:k=]g ցl.0\&ؾOfÓgjM ˏEA`mq/=mMZX{;.힣>)EL9l,[Er01?J23@Ò<8eQ&`؏Dh xD!}2k/q#S;ɑR gf<98&}+GݫH嚗HU$Cf'K_d:[igҨIw8yt2k!L{n o{,`6D 6*;)>p}J9EVl}EYEi.RT<9yYKOe 5iB(Y|k)ː.mk.N'̔>4k '$J $~΢8UqegYK%Z[ʃ0}{Ac#%7uL %'Aq^9$-w^>z/% cEr%JçE+fj`D|9nBj'?yCL( :Ox"D-,M󓆁}>n 担zb U٢:^FrT6j)6(8L(*ZQeD:M[iW\w^l'ޏͭtu@Y 00DSKs(`1D w y1%w$ja bY4|vbrc]Lr}Ĥ "VFtZkyB=cI&Thv|u73v_Ebԏ_-LaM{{YUvEАK!EK"d)uΔ1,1WGl\پͻA'+ ={ 9@V|vJA`sP☶d0ͣ5N%C1~~Ѱ9tp 6)2O,Hc;o[QlqUQL/-#b,g7TF**&Y#ۡovfm-sTc3V c%4<=KNÏ,sc:o16q DjŝS{4%hV s308pyzx>+ƒؕx++0p #!+}dvNwnЊ, Z>L=P .6ѥnY Ħq+8HޚܱGvcE-߯қqZrG_AAakb1?Ů5@"a*pqGVT*9YB6&erE|=NdB;D{  ΢G>`G_o業ί*)2NXIv5LeG$T kD'Ζ˙Z߃*pp:pB Y Ug}lMqj8ysVJEvs#o o7Zg,يj*1Vr(şyF6|秎(1nIVf)gAP-6!o}K (cĎN8>rBf/;8x|x{Ty,#k шl׻\DR%yՕVx%Rcɵ"E\e_'xɳ>g &/NEUhr *f!F ws%my"o4N#WL*_ܝ+: aթqU&@u;G}]RB{RM[9u||{6;>_~/L5eȶ AS_TNcEJ\h5\ Cˤҵ-` A.c%B L{Hc5w$Y !ہPX>+4sVQ+ʢA~Q+UP.aI!:<ږyGNsȔ'?2^l+wUߜd,F1[w6ߊ R-W!>z-k\l*;Eei %o*;;KKR"nTZylP$uYawm r P&2F;f6KM``wOqLNm+`2Z4'#FXBMdwyÄK4;I)?ߨRq$Y[.t0@H.nX4ȯ%t^~cҕt>[]&X=/ fAeyN/iͱ޿f>g؆F U. Xb qe+a>rdnvQwYCaNS~bo'{* UCLYaR?[fH5ҥ1~97` zz.k g*HzMh}j7,ICK˖( XD)sԠH]WĞh& K@9JrIH%Z+pHZ=Z.`zENoIU)DSPMy>}yv6p Rc%?ڶC[2wbVv-% ޼1 )pی<$ $' f6  1{P3퍘XYW?ǖO 6NQyU=@p̺C'g6 6P\4 .ic 5+ؙJm`4)M[,r|Nu*}eSe,tC5mL~ "X)4*T\bsj)!Kh)?2R1 )T }O/=KBdA&zsUL 5lGW,[mAI5e}k 5l-ܛ;+TzN/NM9w.+; 8FփIM6 F]f\"˔u˴Kק;^r *03ݦ`7+IX6gU/ 34Iր:*-m 'Qy(׫SmtV>x/qW/( @R%=\m ]*u8 J3[y*K7^9K~Dc%"<+6+J S> |tx/767Q?y,㼂3G~\> Voճa|Dy4qZ?ߎcV_M nq(U̶#UnupQ > WQd5KN!}Wt4g RcVNGY |%]`ĬRϿ/yWjl|d;sM7oͤrŏݱs!,Tڦ eS]vsF7]7uF].ߦġ>.xk'U!ǯ b7R{6km\%^x.x:|F{n:Pk'j/ P*OF;K꡺r/J@Cݩ~gj 2`%LǶ] b!$ŀ"fp}yd(Hy/DI@HD6vW\g1I9 i谻o i|@2h_c2XmX|ߠNݣM~J4G?4#hK*K:]o:ȇzѶ>y ٵ(v*-ԉI?y̕شSV{KCT-/% /;c xq<`n { uU{҇׳REZM6L&-5(pi<\O sP[{zP;( iL#'ZV I.mCӅ; u`Ob9 PybKu3U!VGk\\pKņ C?#R$)[#>t#u¿U{W&}fXEzRa*ru@ϣuYd7p@f>մb%M͐sV .&wC,k;<7WȟjW ʹ"0I$~5l{R |(/lE" \'/P>9O-3^x:+5-rSܫո&],g؊&Ld2C:%K(YSo%*xHfiMAlx(̩!B +cpѻ`yp]:Ld틂:ȐSGRVP/n͊@25⦒!OZV}KN\$Qd{7抌n85(s t #J6H62!Ds9G/Ea\I2@* ˮ'+Kp' se?=mSBBӤBUUS98'Fsn_4;CD1#l>eziUI6dzw<!Ub;j]koBW%x&h6?:K~lh$5+奴څd0)xůT`m18[HRѕd?kYNSrqDVchiW{Sz F[t젚~F)PD3o^pvtVY/A_.}m[] d(TL_l<{8zNGAl`~pHI+-['4VAzsEyr6š\KNJb>e`uH߬/޼vnԍ zŨIypxD}Kt^;Y7EF zk,G5J~P}tr*o:sE}{Y @t5 .Ajw%JZv.7@ֱqH0t#10[OJMx{.gB5_r,Bqoʵ`c?g=k鏝zsGׅMgX;^5Xj2(XeeK6 > ӽva |I)Ljg3r[5v.2k%Wa踰Ff6-XG%9qCQl>bȝAŢO'=tJ 864pW=z!0ɌˉsdFGR Cg&e~,0h̛:/i_եi| ]PqHJ7vr~ maztXZȧ$+I"u* Ѹd! O 9-2GTҧlڎ>i6EYq|_F̌S">4WKFE8c hA 6X-d^:Wp y)Q"D//U,hbwJ H:Wp^ش/nu"$m3Ҳahk,~}A68 l4XݏAynKz% }h فɶ@H^* t,m96+iHOKj NԴkpGjHCT\"S뚂5rsARǶ1t "0d"ßwLK ҈q PMi('p) Sp+Qpm .TJ&}*3- $4&w*Mv:™vIh eYfT.Ϛ#ՠ(0ْFٹMl-Uap J] v~AyJ_).D ʫ":THFՕ.y$[yYQJj}dxA(QÁ`M*}T(~FnЪqs\xQ7ݢM[& J3gQK_;nJ?CLFdxVh%y~JLB&[L4}*-,$G_>pokyӱ fo '%?H^M2p Dz"_Mi!+F6k!n vu{PtcpYa:(l$|7N?+OO)j +=\yt=_P J8G 5g72$V`UD`?0nA`!jW)kV6H+3)x&3w5PGF֜aԷd=H8k*K 0: XX&u%&Ţ'to詼i\$j=l s():OL^R%6Y;Zq x r%`/;#@ZGRn!α‹x(x{;+홂4A֧Oi_)Ì.=."#nu޻^Ar/CEd2Mx(qC"55IEmCElpYz =\S'ލ#Gxf;父wZF`Qq^B*]핒:T(2x9H=,ҟjFoZ6=n  ~ 0&wdSn{G-+D0= D墳Ʌdy>e헊g$o7beߘB」um9uL Bx2ޥkP:&^3#l r{{*HZY$a B_FȜsQ'{rqa|*:_ئߧڽY/?q< ~T t2?h~J&]@g';uQ(qBHq"7Np$>Uu1{Ә6GeB; cq)i$2??& 9,$ݭĈQg3}I+t0Œ&9SP!BU̱K{:תp"1dwK2#?0q <7$~WxFTxF!.Bpz;'FxzkHX`= ?`yF,md k?(0`j wT*G9͛_:]ZSo &uKS,a))銀v+֯L^ #/}tC'[+1P`'YTN+)RҒ| U!n$zI,?(Z ǭDn" Fs+{@lmY'M1Z[ |m!U0į\)8[Dqi;訳Δ#9E]Wjפ\)JC3(Οɲ}}~'KmB,Tr{OAC/Q#.tۈ ᷀]Dܴ~ P]Y4 oVf%BE$f17+ahqS,J9;z`|;}adģKq˩IP }_!G>"I.B̏*#,mPǟ6RaPvAi6MBcg4qi#K_(~˧{j4;^p |G1 2:2d[HQXFYMG=c6F5"r&Rx5YiOy*#eQ^/[+(.qаñ5Lti۱"A @43RjJxpz$áQ6(ғ*q.7'8}6.l,kxY]d]B֦ğiёmávQ6>M y,-I:!ΊzxҊDdTLb(?\uf\HW<2^< ƏOE7ζK$߀I`1]TlUVft[5 Y:2]W5V.ܳe,]f~P h;%{h~8pbXkp(9 x  unKqM7cLtuKr酥/RR2~ 6B? a/]g\tXVex2i}i|Ll ,a1zI0wjZVuD*!dlixzz3SM@[3*@{ \>τ'-gLAO$vS|o+ү4jW6#l>rNiRML#u$>Dd9leFdR." ͣ>P.m*L>nOVҁjZ'~!:+#`o%I GƁFL遀?ݒVϜWե׬7P6E7jmN}OQ Zoo8T'n@γ7JRI\x15%eDM'o[Wy = ~5bgQ=b곬1.K#!6(B􃄅LVw>% |w;Ղ/bө: ׷Tsw΅j`o ˃@)~bbM%Kk.6Iʱ+NZΤ.$іCDz0błĶy˼lx>Nod~_2z _ZxSfW 8'&g/&u}+ʖx4wY J_H[wOSu+s j렜uO㮍ĝHAGgqw* f14ԙ)SF[#kjH5 Bͪ&@}A*k-}e*'raOb;VHu_򶮨_2h3i~}qҸ$%+FCێrʲbPnewCհ9`Lk;>/+W:9>#7k8k?k_{dC 2`23 |+{HHex@L4,.m*yq)z PY@ @^,y qJ.y?鄓2̹zQzܭhfmxxֻL +R|rۇū?- &pW x+ d`o͞'fVюT֖G{uVk Ԥ~jQAE{y@(9*7K aNE HL7M`fK.WmSP2H#Ldc D]z$J-JfqRp0¦Oى+s.Y6Ǟ>q0rV8vmC@yX)b['1$X/Weg8R=9Q3UVЃ"a*$_دn@A=^curlx)self#jumbf=c2pa.assertions/c2pa.hash.datadhashX D|,/rJۋ 1hk+&ʴFf&rcalgfsha2566jumb(jumdc2cs8qc2pa.signature5cbor҄C&fsigTstitstTokenscvalYA0=004 *H %0!10  `He0 *H  tr0p `Hl010  `He a̤p'?%iFBgzdVO$/]O9DmJD20240416233907Z Kr, 00D9?_a0  *H  0c1 0 UUS10U DigiCert, Inc.1;09U2DigiCert Trusted G4 RSA4096 SHA256 TimeStamping CA0 230714000000Z 341013235959Z0H1 0 UUS10U DigiCert, Inc.1 0UDigiCert Timestamp 20230"0  *H 0 SE[>T#ϟ] /Hz;*gbXͪj)bciX5q:P ǚ;/fii[+ P0hʃB $j;]E alq^<.yfR>_CӄH-^EuuRGx)9kxYD+JՕdM#ʆ!dpc.$_v}1eGUJ$/+{s>2R4ԻԠ,4nd7QͪLfhbAxmXAر,Qbi|dM^Pɳʼ;hD;Bs} y4~\ XL>iuǃdu͏vV$k!4/:k*{R8 qlq>oaG l$Bʠq=ip' O6_p .d"+(!IQ~f;8QʔP:ӊ@{00U0 U00U% 0 +0 U 00g 0  `Hl0U#0mM/s)v/uj o0UdVe1I0ZUS0Q0OMKIhttp://crl3.digicert.com/DigiCertTrustedG4RSA4096SHA256TimeStampingCA.crl0+00$+0http://ocsp.digicert.com0X+0Lhttp://cacerts.digicert.com/DigiCertTrustedG4RSA4096SHA256TimeStampingCA.crt0  *H  ޠpO_B֏ѪUㆿ',AК3J6Թr~y8H_=2u6gZO5<*lyD:8;^9X|s1U ~yeh";뚂5W(i2:Fkwlls:IF̶8C,NL}hpw \`(8RZ֬"#NPkwqDAɸFl2|X/gGesk,FA_٭DA0067$T|G(f*^[0  *H  0b1 0 UUS10U  DigiCert Inc10U www.digicert.com1!0UDigiCert Trusted Root G40 220323000000Z 370322235959Z0c1 0 UUS10U DigiCert, Inc.1;09U2DigiCert Trusted G4 RSA4096 SHA256 TimeStamping CA0"0  *H 0 Ɔ5I=rIQU%7Q҃ўLm̃ZDB_h} 3P &smW}Cs+"=+>BgQ=V(-ӱue)iِF{DA|jWz7y]dRvGa_T !hn7!@_J}9gcl6 \dt@rźNXMy׏s,9H1W)'.NvU&p&G CCc{un'%:8;["ق*ǒ>sZlR+Xt@(sCJk8)ʪsBhF:^KvQɌ ;["&}_#dc>t? v]Fu`X (T]^0Fvk 3ͱ]0Y0U00UmM/s)v/uj o0U#0q]dL.g?纘O0U0U% 0 +0w+k0i0$+0http://ocsp.digicert.com0A+05http://cacerts.digicert.com/DigiCertTrustedRootG4.crt0CU<0:08642http://crl3.digicert.com/DigiCertTrustedRootG4.crl0 U 00g 0  `Hl0  *H  }YoD"~f!B.M0SοP]K)p )ii>` \[m %41gͶoPLb Vs"%Εi?GwrtO,zC_`Of,d&l|p |屮uOZ](TՊqver#'D'$&*yV Ečrjq Ķ͇$OIwfrKR7~S;I9z%c',=?kfAO@!!@з$x:䞭4q&k8sO?;xLĕ{ _39Axz8#(_+~Fu,',&o{6Yp7 O'`gfU:)+A:1b  Wټ2]# v&evB) G+UT++/DJ78+|00u-P@Z0  *H  0e1 0 UUS10U  DigiCert Inc10U www.digicert.com1$0"UDigiCert Assured ID Root CA0 220801000000Z 311109235959Z0b1 0 UUS10U  DigiCert Inc10U www.digicert.com1!0UDigiCert Trusted Root G40"0  *H 0 sh޻]J<0"0i3§%.!=Y)=Xvͮ{ 08VƗmy_pUA2s*n|!LԼu]xf:1D3@ZI橠gݤ'O9X$\Fdivv=Y]BvizHftKc:=E%D+~am3K}Ï!Ռp,A`cDvb~d3щίCw !T)%lRQGt&Auz_?ɼA[P1r" |Lu?c!_ QkoOE_ ~ &i/-٩:060U00Uq]dL.g?纘O0U#0E뢯˂1-Q!m0U0y+m0k0$+0http://ocsp.digicert.com0C+07http://cacerts.digicert.com/DigiCertAssuredIDRootCA.crt0EU>0<0:864http://crl3.digicert.com/DigiCertAssuredIDRootCA.crl0U  00U 0  *H  pC\U8_t=W,^"iT"wmJz/-8r$RN*-V0z^CDC!rH˝Ow'DY/ 4<LJL@5FjiTV=wZ\ToP=v ho 5` X@cŘ"YUk'lvo#-~qj#k"T-'~:𶇖[\MsW^(⹔1v0r0w0c1 0 UUS10U DigiCert, Inc.1;09U2DigiCert Trusted G4 RSA4096 SHA256 TimeStamping CAD9?_a0  `He0 *H  1  *H  0 *H  1 240416233907Z0+ *H   1000f+2]ΪO@0/ *H  1" ?U!daYҳ句FI@07 *H  /1(0&0$0" mt"@WhA6oU3M x(0  *H \|?*K}D$WupGXͧ?3Y"kQf.H vbQN1{_r, > 1&$:p QQ_HѶG۸A(m NVfBud'=n~D>@|[bbc\UeВ:``lZKn夷0ͦ\_&Hm!"P^+JgYp,ӔC$!!ngGHa XZ S -%?V;Iؑuę8uJ_Ӵӟ d2mSfrj/>9BjNeQÜ&9135Mgx5chainY-0)0"C`Z_|ɪ׀1@xv0  *H  0J10U WebClaimSigningCA1 0 U Lens10U Truepic1 0 UUS0 240130153453Z 250129153452Z0V1 0 UUS10 U OpenAI10U DALL·E1$0"U Truepic Lens CLI in DALL·E0Y0*H=*H=BS:WT08;Ea18v0C4_xۄ3æuYgz(pb1h%Br֊00 U00U#0ZkfӔA} {]sKK0M+A0?0=+01http://va.truepic.com/ejbca/publicweb/status/ocsp0U% 0 +0U 8đJYX0U0  *H  "**64Tj3X1h|oԙY:V\F &).bY…S8lAUշ1k͒ q 0rWffWE5I^"DSvTCUfؘE:0-56&Γ,*“r^a:[&1mUVt.#^݃u`Ц2|VK=Fod9m2c4bZh xoMnhx#xY~0z0bỉP:_҂(0  *H  0?10 U RootCA1 0 U Lens10U Truepic1 0 UUS0 211209203946Z 261208203945Z0J10U WebClaimSigningCA1 0 U Lens10U Truepic1 0 UUS0"0  *H 0 çPkjr3eA`(k "ŧ (b; yeyXɭBa]CPoAl%] i*+68k?~mPw&G8JK="?Ro;^9t.o#%3`s߽> 9.wNBļ-A"TL\U݄@hi䲻JKu]s,Dfa'qP#!Q6Vx UtS30IL#7<>IoLlH{Qߌ|i21&! Zp6)om )xUa*{P\Db݂'cpadYzX@67H% ";bX\kfSPX?eFfVne8G,W 67U=jumbGjumdc2ma8qurn:uuid:811915aa-2748-46a4-adbe-66902688c174jumb)jumdc2as8qc2pa.assertions4jumb8jumd@ 2H *Cic2pa.thumbnail.ingredient.jpegbfdbimage/jpegbidbJFIFC   %# , #&')*)-0-(0%()(C   ((((((((((((((((((((((((((((((((((((((((((((((((((( }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?@ (P@P@P@P@&ha@fh -4f P@P 4 ( P@A@@ (- ( ( ( ( ( ( ( ( ( ( ( ( ( ( ( P@P@P@P@P@P@P@P@P@P@P@P@P@P@P@BP0 ( (@ @€ ( ( ( ((P@P@P@P@P@JP@ @P@JP@ ( ((  (E (  P!(P@P@/j% ( Q@ - % Z ( (A@ P@hP@(A@! ( JP  JJ( ( ( ( ((P@P@ @P0 ( ( (( ( (a@ @P@P  JJP@ h@ (P@P@P@P@P(P 4 - ( J(P@ @P@P  ( %@ ((P@P@P a@P@P@ @ (  ( ( (BP0 ( ( ( (C@€@ @ ((P@P@P@4J q@Z(  ( ( ( ( ( ( ( ( (@ JP@ @h  P@P(4P@P@]Y^\Cޕ0wFeFUmB ߖbFt< 7xH9oO2 c5i_YvdI?/qW3|}?qkkuowk _Wju+4al[6ٗLcFv=HW6l+॓ƾE摱kx.4y%caEOx-?fX@tyb740&z4r!VUݻ{P(2~j( T1TcxWZ{Gx+)N,Ɩ9"r# "Ɗhh֧^ލ]X܏io)B}:cIml{πiJʶ whsvO߆TWCX}xwvu8nF^,!}zzsU$:ZEP@P(hP@JP(hP@ @€ ( (JP@P@2K~&5Ȭ4U'IdKv՟,M5]XcJ9Sw AQd4U/Z\sp9wcO&͑Gꑫ;Td˜HF{{#ADM>fNw' FNeWWҼ8)#r XF2C,f :r.ž)uo:C>[0<ܟzqn8(׈A} ws:*ۀ w4ΦovWerm5+>/[fiz&V\8?.v>7wfjk0SQ8oyƝ4?SSdq6>")a>C5\t!е: iijP0 (P!hA@4A@ŠA@P@ @€ ( (4(P@P@2hƥln"C+xr3>:Ǎu3 P#=ɮGc&9ʢN ԂsKc,>f֪1[ݭ[O183YN]+DQ6sz1Iz? SqY5=N"g *.iʒ?ce\󫩹48zCk#-T7"bF-OdЍCܸ@{/A. ]ǖ'~*Ϛk3`.h 7Zt>w"( 4.)v:Gvǫ[cTVtWzV{o=| O ÏTd'(<Q2uƢg/Rht ~YuIm/buUF˶ LkcOG7sjL{=sNf@P( ZA@ @P@% ( ( ( (@6mB$WX7A=FХ՘ΧD|<\O$#wv,ORMnbYtVAev&=IM&ɔU: j2G,7d#jSrwln33ȨemP )9b'*|g[:z.B5 GIe0'u>+J 4ؿu>1j'jdf@La@ ׼q=,ZH4 @,ǁ:ԥ$Mz{c,cJp3Ew@k?ktS 3qўoiwMϑ} *zP{m[sdة$N"0ee8*GBcH__`C%"859>샠:'K6NH AwMC ( (P  ( ((P@P@P@P@!h?5φ<sf;蛙csԏqNڳ #*𯆮5[aaw>"6R_E?ȪA8*x?>@8"i,v:~0j?a<5顱=R_ GOO,-ܤxO-#ğTHp{0\)"&/h@6<; || czjM(iyKhʉ>xku#>*·7ɏ.>z~g(3R9Ԕ{rx~Kјsh{+?NT}Q'm:ȊU#V0 (P  (3@ @ ( (hP@a@P@1~ҟ ׃/q3w~pЏֶ¤#![72Ģ2X!2AM\cs:nz>tf@#?kVssSu ۝B/%ienڹ}ĒVD{jqX鰙g ;PCئ}VC)=[u'{*?җ4,e<ǿ lXJAN^3-\g/UgV̻z }]i$fwe}KWԢ,ݑ{V݈UWԮ-)x?ֲQ̯9jxԷR6eb}ԕ=3Uפ?k_s?}rGC}I4N[BdqG={invo[B!_Ul,A'CVc5ws99ᕆ> :n.:,<;mQVYN.2Ekmv㣸i@=i@SC5KuFg.G:V-JZjxk@MZ%wӜ,iwbz;[+}?xඕoOO00ސzq1b)6J1XAr:.)FRLv<`9uu*՝5՚_ڭAxr8OZn+JEWl⿳s#@3{7@䬰\:w$ -htcԇ A[ZFvљi dh半0R:+S־|p9 ^#Ar[]aDf1-*%M=VmhΗ tOh/3 syއ!_=ͧ!VGeu*pA bHDM$$$w+)ZgX6%!.=x{#Z(;|SˡF%/ڱeSѵ;UԴA,R/UaǷzZG-< mVlwKpJ#Cy]es ( 3@@ @€ !h(P@ ZP@ @€ Z|aO|B)J\}%S7_?DpklsԕݏEiQdZz|3,s\vF\G4 Tեc˺GA2b-l%qRl?뜿Q2|y#_r:`;_V$&a$RW8_¦W6{%ᶅTdTЭNu[YI4gJ&ڱVfw ڇ5]+G{I$$vW`ϩfgn Լk}gc%9R1u)EE+QdSl?ƒ$$­>z5lǡZ=y{+QQ\ϙ+3P tJ ~\Ig/T|]/L9Ԣ3GpFg8Tx%lbt|q^RѦ;Nw:z0GL[[[ú' ERZ5Yʏ^ÞYG¶24jS:WvC^Н%̬zok !ܮOBO'WT)"0A?ox)na_b sɏU'?BýEH".V}4I,Nee9 BrC% ( 3@P!(P@P- (P#h?]KLTH?\fDʏ:Qv 4u5-{sUks:7lt4v't mgMϤ? _Gg6:KjZH Uv;w~\홪QNܗEJ.[^>?O@-> Chn\Ie!BFJnDvxCMb+tCr3Fsgit2O4.ԫq4v+&ot}'3JsTK\KNYK`cлq kϕrfsWE$# zUdLW34N,OE* C `Wޒ^7chb#XB" Ub΄I@P=>m:OQ"v"8Z~+oa\Y^fx}'_VwFws/6p@Ġ ~_xH%Zcf%ࠟ”ƕw"< M?I7yqcYS_iM-|.+c6 jVsS+c -5OLВ:ER>H [}XP@_bqȑU3j-pQ6k\X7vq?UWG¶0Hg>\um Ց< P>+){L{թS`H ڑ'xz/xh̦ Ǿ:9Yڭnqg?\}Gcɫ3tvx)BxǓ=kM>~r=*T-\[⟉6f[J&Y0î=6N!:9gmMmGNjn)} \ὙVEr_fy^ owN3ʷjԓ؆:5ĊCuߞ'񫋳"q:ZHha"*K߳Xӏn2ybmcR>Jtzi,JP@@ -% (@ @.(b>a|JsfG?JڌzU}s<7zRYұ}IV= `HήΤ1n%c[c,_:sŤ1hmA!Da5i˙jrgogJ-G5@2Xh  HdHmkXV2 – z~] N-xִՍ$Ou1'ٷ~[Hు}&B?q֔zlq5 |?o1xķRƬh S$I?F'[M>ĺEџNI 0#؃F(`XM"!$$G*0dt8*GBhZwǯh(2dbC zLQ4/[DT]NV#$;Pt=Է.WU].N߂%}&hgKi,? Oҟ4rŜ+Q[I%ؗM_?~2*Zc?m y7߲rGi#$A왑w7iw?v;y@^͕~#[4ooۻ{I!!_H1iHO_/RcqG<{$ +^OݩG!L]2\g羈1D^D9@eX!7kQbF/E/uOP2?(#Lփ[H:\~#K LֵpڧTucǚC}dW7l~2H{WQuk֗NFQH!)ϓhmʫI~'դbg!VHP#>a,@-\ed-W/6?,vMD(ZzgzG(҆n2DV1A'`M|1l4+#'ҁcb@_¨+i6+=ܟf=)a횙^6TOi(])Qw$t$Ƴ5yԟ lmܙW A{O"i SA؃œ4vXmֹв"(cF+ЏLA^"R7[H?.D?hptf7o{$ёIBk?տ2J~e= #V{$єgA,FGO ̛NJMB?<Щ^љE\eh |̋kwYNHr.fe#Ťvv=Kv߆˭X׼G IY#@<$d=lY36РtDHAL5%yXN?jTaev mas/VyA묝k>b?QZGLzQ,JP@PP0 ZPPP1hVZQy~QҖ"Ns/? |'|.ѵC.ē<1@yͩhoND/ߥ.x+֟tܧފx:AS^iZ(A@@}w'$J>4up#CxwwRyN..2MY?>hq?59n<C~E8ɽ.)t<5O +oOl#QWhw+(]'Fn\ۡcRzt-YH)FUv{z?J"NJҸ%M{F>Ta_Ũ>{hs$gI==$WVkdyj:R L[H|=H(׼EoI8fKcEjG4}ٞCY'oZQos>D`(hP@P@% (A@P@|hj4 iM< 3U0fU^Z O߆ךn6Z6Xyyk5%:bG~-xNb|/iNdN} ߄ܗ;5ے'bia30>r*VDQGj~|:*+;FA5wQ/0/1Oذ|a⭧tc1l}J6 G_nh5/tS$.wMJ_ok ё*}Qjd(PLAHcI~*z^wd[<`z|LT8&5&㮥&wBWIJ1RX~>ϳ+_tg|^ 6䜟M.ޅaWM|Ian lzaƤZϩ(m$t~$G$B"UtiچgsQk+dbn'?3[Q٘|[ܐ|Lӌ`yH:O׵kk[ib4=z{=*˕N7՝GKPmE<JvTϘKwu'N){rAoAuMu}(lbhO_w DFsŸ j ]T]G|JOOs6ɖêˡ_7""ִDZ^Y|N9p=d0USՙ#gs+S1i(P@% ( :Vgp&PNR[i0[{x|QI/X乶@걑e8A+Qf\>yLυ4_Ʒg4u4Y#±vFjMZu:IT;;5Rx7L>,GEadzxv$3];FPG|\2Z[P[FB1ܑPm~|PI5J,ϗMF2UFݬ@1INhi/c1^DZ)"'O};-Nun=+ZzS9p`Ou~tmou =ye߹ gvv*Zη1 b@ @<xm|13D2zaE{nQ?jX{yrAW_U#(w2P@%[\<,RX:0@}5|t,vzj[j7EEd~5XVν>v0HI.m^%o`kt̚fHzb0={"OL/Zjv2Hw;,rp:VRQN.V3|7k ~*jxE2j ώ1y&.r8+qdwgZχ]#Q C{!GU=y=8BR5m)2=x"I%%=Vi^]ڌ5̧,{S&L ``PGTg}Efto^T"q&,?"?ְ筹jCdЍf6gL?|4ޯ2~pH?eSf# : - ( ((P@A@ (n|JӦ_6oQV=M Lϡ~'EýFUEr85R5\ׇ%lu?&S-`y*U ΋zx1oZI,e@]]xΡ\.]|oIo> V)19aqj9l/_j#%xu;s|b#azӷQyhb=kllC{.9U5hާNm3`%y,4y*#9i*cٙ LB޾ɭ,mzZg-݋;YrہFHҚ!2~?ꖲZh:CE{Y!7s&>SA:#8)Hq^A`}sQ=KtG2+"ӞvS񏊟 &2$Q7JtL֑rwּi4 6(⼀3\ U`Xu8$ NMn䒹cwc /ajר2^1mՃ'YDд5K{.m_sSޥ-YoJ8|AeirQny;i= յ>h@wzuQyesoq剐7Б0*PiLo&ˈݶP$SJRvGEFvՠXd6PspO*5k9ǧ,hW'#Z֞=m djx?#Yͣ;OՉ<Ǫ#YTY?xW1P@Bh((a@P >T-u F>O+j=L*|[1īn^p3?:J ꋄ >1M_Jyҁ^;Н5fk_mI4& !e`0rӞ9YԾ/`v; 5VB=|_3K@שޑH.<[:2zSb \k'={'>}w*oa9;+Uߏ^ =˸a='8/N>{:P!a_Ɩ־[47 ?P+'S޹J폑u"@.Zݭd1v#] SX) wٗP}ǖ 0y#_f* |;x^}@iX\K L @'M%A\j7$8UQ?VVFMݙT&h2h~[X]|B]IQB[ n#P)֧8&:Ǫ~,`3 ï1Lk: ?vP 4 ZPP0 ((4 |gX<9Gir[@_kGvcWk(WA7OjEk}{RxėX+( 0Gge7⯆'U:i2D|iC7ߥ5%!84w^=' ݸ 'ʓ̍-N-jR~ fίo]An [7 =㭢?K,U{H~ o$sVQ6zq,OJF7Tgt/ i= 1גi(jʔ ZNM2TftzqMYϲʺaJvq=\ c憺ރ6iSgKzxuSS.cxexFDb0T#3% Nv_ ;]3@P#W:3z^LGsFO% Q

("8UAtvM!tۢҢKѽAm$7 ?oRϟo3v SH68Y1MmJ]HSf[(zo覠Ocǟ:/h #S*m#!vn 6#CfcospAi1DR9> 7Jt j>yG3NJQи~*~PX)x 佳2]?#w*zha {-q1#vC;ӕ4A`Nf;H1݃ѿ{(7J*G0_xMq])đ2ʰ+:ɋG{lk*cFs;g59xsƍ / /2mb# = 8 7JJ ZD隍 I7'qL'kž$Foh0\ϩJn%2B %P|Hw nyq!;cЅ%=Ъ繝=/YĶ(FUoJ*n>+]>R3gTy)>h(kY͕QGL@ S@a@ @ JP@b(a@->F~(Hcw>|`I_ uVg+n|?ng%uc~&icAlji;;YY ZCšȉ GcƝS:OIqŦ+aK35 #Y;Yi;6y'B{$J:ךn1~*C%?%^Gbs5V؇#CgNmSM9KHyVn &}]K+ qzd~;L]i8k~#&oy¢tQ)Kwʦ Ġ  P6F;mTQՉ~t 3iR_^Ř kx&69x+j2cUu>[ؼ1qsc6 gtsݑwZ]o0đ9Ft'}K~kd֬gل~S[WtItEbE1:{UINWV9COúHSuW^ø45qc3KKxyĐ.,{ShB".m4M%f]F28/n}NqֆgXIq%Ȗ+êGu>>H&Iar!ܬ:Hp DŽWg4Px&^J ؙwBxI~>|3T5,h91霊fN7GƳC%C:4r`[3W>eB\Mp߻|0=ǚ+լ;tGĬ#baLF{'l4CZ]34ӹڈTviwz&gm6/Hc7Cw~k7%1w:*/eQ nqG_ .wz@^ U7s?"n#n<)'Uő%Z>IgsʰzYgcTg(P 8$1'tG]NcX-Ď'8!Љ;<3tk_]bQ.n+ ''zs쇻>"~?t!ny;,p$Y,|@B'On;+j̜G-=b}RMt-F1qk*7ӎ=?Fe5oy,WL֮bId\]֤QwDo4]N :S-{Z}c|N}CH{f8[w7{b_Ѧ㉼痆6?>TRF{YZdksLzڧ} <<=m,83[2I6+S;Z#f%cWݰԺ*l"| Q=NٻIuԜU=3D:q->!၆1oY%QVRJ|@mi. * ?+83&4*D3YU֫(!#ӠW_s9N#®&u6g?y@627M~%߈\Yܟ_7}Q:{jrj3's(;Tl@ b=WAZ I" |ē֋ZN< jqʹ$ LT1;MMn$ 8|v748 I<* OcO,S?Zl3G}#3OB5QGL@PP@PP0 (P 4 (>|/=~H GwtӕR6gC+2K2Zfz~-۵e3NGE#G"u%YOPGj:/_6Z'ir*|$:Py{ 0 5ōw6sIm$FS3mn+ilDc@0 uf@}e[?T=/{[}ಗ+n"IiZIF9fcOί:xK"c8"6 =0_]\yQ1rO41GchDvz4w_.3ǡd֖Ks.i=QxG]Xkh,H)A4B·*t̙8n*W0KMW}yq}rr7Vcp=-΄v c{.i,^r;Vszܗ]Ns$X{VoSt2e {˼U#jKN<ǵra@ ZP@P@ @€ (BP@€ (<߂?7u[[n9fQG\z.VEH#$;WQv? uojma+~q~U3J|S>?{5T} χ6Z'jcu6!-!/ ZՖجJI``~$RyN\Ewh:I[,3ӕ57Scvъ>XT_kkdIn~8gS.x~wھFê7E9GNׅΫX7^=v3g)ђfA<\6V2ϡ¾ u) EksJb:jw/j~g쉣Evr?]$?o(!*h giΑtO$7^'ԢA?v~GG?b;< |kCsM 򻉥gyI\gvcOzFSiٛ6gq_d-41k< ݞkPju iIS)l'Ō/=;rWzֈ/+Xfdkh .Wdyη˫RN`*۾\eObF_ 1j οΈu T^٭0jN~/ZqZRVV/|N [?; n3NO4mN Tj~˾^:{/[` b0~sUwgM(\Z(@ @ ( ((P@P@P@7O M{O.)q d sԍFdud%YNAALZm[k 6N3ϱԑ(;ޓ>kk n::l#IIJ&Gl?Oj%PQO.pqr~T'gqRFК0K5|+iKqz2 jN|J©>D2F;zAjIF\F>P.YP(K{G#0ez NVw2t YΚxιli3 3HkS_ֻe'DͱѲO?ME/Mt-G?F9OJ|ͣ7INA&D6`b c,[%_3܌ mbyyvӧ2(mnm1S:(?Ky$cOvyA%_!Ka>,/Kp!HO'60#Ϋ3c|g"E_ 9^\jZ]Gr}vW=[QDP$ RG/!x'>sG,$Yؖby$ӱ?KDnc?ؖ ^=؎}}:E<5T"(UQN (@ @ ( ((P@P@P@P|5c _蚚Gx4l9W_p@4w2Sw.˫Y6::W_b0GֺӺ9ZWC5(+.xu Rvԉ+fZvZ >̴Mr ?OjYsYCw#]'`PP}:3-A-=Mp#_<|4N/z-Ƈ5tg+7FJJUVዃ.qd.7G'QI[|NoB뼧cnXu~ _Y"Ȍm?>=`c(S )B(b&yXeTSڀ N|,"Zy+#a`(B~-Ǧ!W#{ͪMN7 +觡n`#lRP!',VƧď<͏F\2\xB?2I1Z烴f߇4{o \^: _Ay?8]Y2;<ԺޤӾVb'fTUsW4{۩QFC=5-Rҹ=ט~p1L~}8{\r3ʬu (P  ( ((P@P@P@P@/L|9ZM]x:cx֔gR7GŵsD%IkOyD̎>]RkWe*98a?Zo3<Ҳ7 :7hW:U(ZxF}?R!5Cdj1iyiMK{0Gdՙ_2* P ikK6Tᕇ"u 9 R #w-,G zSv$y଍tSBL3BO=8t-?yxeb,"=z=+9JE8rR e^|7kmocżN9r+:)ڳ@0 - ( (P@P@P@ _8ž"٘ɨY 92njU:3 ê>m̎,Gt&M%zlyfl]3a_Bw&[3)e F9 mOWPΓj]BH zGsI=W2RݴUx~V{;+Y-O]%Ggi ,GkK!sh=+H-.cUcu;JK7{=+&$ECAG+O4UuPjvŽq7Oǫ?SwLmbO" `qݿõ'+\!mΛziM=&]JmSз>,/@P3ѾA$60"o=-opVڰK茟NCy?t; vqp@ͷI &K;vKsL?qR#JpĠa@P(hP@P@ ((4 ( ( (0 X`ހ>F> 6%lj|'n[Gb^1͡?矷:oNf3GձW:3Y1'uq趚ŋ +xf~W(j_,3a)?T4+H= jnyfl]?5a_BnLgmgN-\<7;OZiZ}SflzÏmOcy5bth.'sO*l4<oOd<)Kfu_vPo'a}9jEwFǥZ=3BF4gԚwo(:OOM /ş_e#3Ҿogc'%V0I-~cѥoΦSSj65=Y<:x|>([k?~A܌R#HBHbc8P0`A\P@P@ S@a@B@ @€ ZPb P@P@Uu*2"'vuawqѮ4}Ɲe'GOG+ێӫ}GXGչFծ{SRkRe-˚ioӱm1?c'G(Qј5%EmO?*13b!?#Ѕ5;?4KZLʏSOJaO?UkO`pU_?W_69ny)N ="IJ4Ѣ8} Cw| rٝ??#Gcj Kz_FTgԚwo(:OOM-#lRR{NK \M_yo2lJC@Ϣ~ TAf 6d(ROAվqKhaNHHEHUQtzV ( P@P@% (hP@P@P@6׼Q}#3دTUL C>5?j%׷('O@rx6Ϯ | &`Kj:㏬6~xioDo%P@P@P(P@P@% (hP@!P@P@ 񟄴_hi\@(Đ}GԜv%=ϒ>'k I-$C@dOs SkckB#vGN #jC-謩3U[$sMn)l mZBxZRyfnw(181YEhls%=kC@JP?h 8]CZ|G rٝO?Y#Gc͎[\E3_xG ;q} \LM!_<_,7r!S=<xgD%qgV,X~ S6f?N)9nnIR00 ( (@ ( J ZP@ @€ P@P@P@P|A9?<7vS~MqW();|@Q{'g tǓIj'W0Mkq$0 ѝUzV{P@@r< bE7_ ɢpCtǶ:_Ks4=~%4moet;7?JSn矷SY@ζ?SFXHUR 8o\_5j췐\"TSWV4|EM~Z3NNyLR((H!d7W;UK3@Z@{'g˸ּ Ű?EOCq'l%jz.@}zUM#j  (@@€ (@ @ ( JP@- (((-% ( ( (A``!!A¿uou hErGktV+i7 ۼiZh˦ hh JJ(OGе}jQ_8mIC=ß{3i鐟i}0_uw7PEzυf *ؤ&֮oXr`A } >k7U-R{O E=cU]Ұ26X&rosU:*C ( ZPP0 (@P  ((P@-@ @€P@hP@P@P@P2R) (|=XoHW6?;r剓Q)uq4ry"c~.Pc4{I D O]_O=&%fdcVAѳe;ݠ|9k-^Ch鴿xOJ*t h:u>Tth*`R1ha@P@- ((P@P@P(P@ (% ( ( ( ( ( ( ( ( ( ZPP0 ( (P!h ((P@/j% ( ( (BP0 ( 1@P@P@P@P@ @ JP@P@P(P@ (b -&(P@ ZP@a@(A@!a@P@P@P@B(P@P@P@ @P@ @€ (hP0Z(P@B@PP@P@P!1@ @jJP@P@P@B@PP0 (Ph(P1@(hP@!a@ P@ ZJP@(- JP % ( ( 1@(E ( JP@ ZP@%@ @ (043@  Qҁ 1@ ZP@&(a-P@ @€ ( P@A@P@-P@A3@`(P@P@P@ (P@ @P!(s@h(f Z4P@P@% ZP@f m ( ( (P@P@f L@€ ( ( ( ( (B4P0 Q@@Š ( ( ( ( ( ( ( ( ( ( ( ( ( ( -˜b @ JP(hJP@P@( (hPb P1@(h ( ( P0 ( ( (P!h ( ( ( ( ( ( ( ( ( ( ((P P1hP@P@J(A@jumb)jumdcbor8qc2pa.ingredientqcborhdc:titlejimage.webpidc:formatdwebpjinstanceIDx,xmp:iid:cb462656-bee1-4205-9e5e-bfbaa7936038mc2pa_manifestcurlx>self#jumbf=/c2pa/urn:uuid:baaffd09-33d2-431e-961d-89f7b1ccacb9calgfsha256dhashX F .8R]5ƥOM.$lrelationshiphparentOfithumbnailcurlx9self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpegdhashX xOTUjumb(jumdcbor8qc2pa.hash.data~cborjexclusionsestartİflengthFdnamenjumbf manifestcalgfsha256dhashX ˚Pkt;Ԟޒwk$p gFcpadJ jumb$jumdc2cl8qc2pa.claimcborhdc:titlejimage.webpidc:formatdwebpjinstanceIDx,xmp:iid:d6ad4dba-607b-469f-8f25-24c8c2dd2c00oclaim_generatorvChatGPT c2pa-rs/0.28.4tclaim_generator_infoisignaturexself#jumbf=c2pa.signaturejassertionscurlx9self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpegdhashX xOTUâcurlx*self#jumbf=c2pa.assertions/c2pa.ingredientdhashX qͮr)+BEH Z =ڃAcurlx)self#jumbf=c2pa.assertions/c2pa.hash.datadhashX S"nc؁ŗ%{ ݓ2PL: XMcalgfsha2566jumb(jumdc2cs8qc2pa.signature5cbor҄C&fsigTstitstTokenscvalY?0;002 *H #010  `He0 *H  rp0n `Hl010  `He t.nJy\gؘkٔQn޺L20240416233907Z^E3Y| 00D9?_a0  *H  0c1 0 UUS10U DigiCert, Inc.1;09U2DigiCert Trusted G4 RSA4096 SHA256 TimeStamping CA0 230714000000Z 341013235959Z0H1 0 UUS10U DigiCert, Inc.1 0UDigiCert Timestamp 20230"0  *H 0 SE[>T#ϟ] /Hz;*gbXͪj)bciX5q:P ǚ;/fii[+ P0hʃB $j;]E alq^<.yfR>_CӄH-^EuuRGx)9kxYD+JՕdM#ʆ!dpc.$_v}1eGUJ$/+{s>2R4ԻԠ,4nd7QͪLfhbAxmXAر,Qbi|dM^Pɳʼ;hD;Bs} y4~\ XL>iuǃdu͏vV$k!4/:k*{R8 qlq>oaG l$Bʠq=ip' O6_p .d"+(!IQ~f;8QʔP:ӊ@{00U0 U00U% 0 +0 U 00g 0  `Hl0U#0mM/s)v/uj o0UdVe1I0ZUS0Q0OMKIhttp://crl3.digicert.com/DigiCertTrustedG4RSA4096SHA256TimeStampingCA.crl0+00$+0http://ocsp.digicert.com0X+0Lhttp://cacerts.digicert.com/DigiCertTrustedG4RSA4096SHA256TimeStampingCA.crt0  *H  ޠpO_B֏ѪUㆿ',AК3J6Թr~y8H_=2u6gZO5<*lyD:8;^9X|s1U ~yeh";뚂5W(i2:Fkwlls:IF̶8C,NL}hpw \`(8RZ֬"#NPkwqDAɸFl2|X/gGesk,FA_٭DA0067$T|G(f*^[0  *H  0b1 0 UUS10U  DigiCert Inc10U www.digicert.com1!0UDigiCert Trusted Root G40 220323000000Z 370322235959Z0c1 0 UUS10U DigiCert, Inc.1;09U2DigiCert Trusted G4 RSA4096 SHA256 TimeStamping CA0"0  *H 0 Ɔ5I=rIQU%7Q҃ўLm̃ZDB_h} 3P &smW}Cs+"=+>BgQ=V(-ӱue)iِF{DA|jWz7y]dRvGa_T !hn7!@_J}9gcl6 \dt@rźNXMy׏s,9H1W)'.NvU&p&G CCc{un'%:8;["ق*ǒ>sZlR+Xt@(sCJk8)ʪsBhF:^KvQɌ ;["&}_#dc>t? v]Fu`X (T]^0Fvk 3ͱ]0Y0U00UmM/s)v/uj o0U#0q]dL.g?纘O0U0U% 0 +0w+k0i0$+0http://ocsp.digicert.com0A+05http://cacerts.digicert.com/DigiCertTrustedRootG4.crt0CU<0:08642http://crl3.digicert.com/DigiCertTrustedRootG4.crl0 U 00g 0  `Hl0  *H  }YoD"~f!B.M0SοP]K)p )ii>` \[m %41gͶoPLb Vs"%Εi?GwrtO,zC_`Of,d&l|p |屮uOZ](TՊqver#'D'$&*yV Ečrjq Ķ͇$OIwfrKR7~S;I9z%c',=?kfAO@!!@з$x:䞭4q&k8sO?;xLĕ{ _39Axz8#(_+~Fu,',&o{6Yp7 O'`gfU:)+A:1b  Wټ2]# v&evB) G+UT++/DJ78+|00u-P@Z0  *H  0e1 0 UUS10U  DigiCert Inc10U www.digicert.com1$0"UDigiCert Assured ID Root CA0 220801000000Z 311109235959Z0b1 0 UUS10U  DigiCert Inc10U www.digicert.com1!0UDigiCert Trusted Root G40"0  *H 0 sh޻]J<0"0i3§%.!=Y)=Xvͮ{ 08VƗmy_pUA2s*n|!LԼu]xf:1D3@ZI橠gݤ'O9X$\Fdivv=Y]BvizHftKc:=E%D+~am3K}Ï!Ռp,A`cDvb~d3щίCw !T)%lRQGt&Auz_?ɼA[P1r" |Lu?c!_ QkoOE_ ~ &i/-٩:060U00Uq]dL.g?纘O0U#0E뢯˂1-Q!m0U0y+m0k0$+0http://ocsp.digicert.com0C+07http://cacerts.digicert.com/DigiCertAssuredIDRootCA.crt0EU>0<0:864http://crl3.digicert.com/DigiCertAssuredIDRootCA.crl0U  00U 0  *H  pC\U8_t=W,^"iT"wmJz/-8r$RN*-V0z^CDC!rH˝Ow'DY/ 4<LJL@5FjiTV=wZ\ToP=v ho 5` X@cŘ"YUk'lvo#-~qj#k"T-'~:𶇖[\MsW^(⹔1v0r0w0c1 0 UUS10U DigiCert, Inc.1;09U2DigiCert Trusted G4 RSA4096 SHA256 TimeStamping CAD9?_a0  `He0 *H  1  *H  0 *H  1 240416233907Z0+ *H   1000f+2]ΪO@0/ *H  1" S}-&Г<wi'Nxzȵ07 *H  /1(0&0$0" mt"@WhA6oU3M x(0  *H Iy+ՂTڐ{vV40<5Jp 2;oޢm:oqpLl+zܠuwN^B=v 6+a`/ L5=8M2qL (G#ؽW0[le79INs8}+Tju~J: DaE^k&}g sE",UE5T->9}Gn0Uk%u`s29 I>99MU' whhcxS0ƶghr9aHR\ccduTObq#T5of>cchv(/ٲf9^~MqRQG**ACn?btQ#Nб1}Ӄ3=&PC-֏aR]Cy[Vu(,Ytf7 8OGOǃs_v赱qV~4Y{d wT2(tE!X)-u!c@: ,A:1gx5chainY-0)0NIknAuM0  *H  0J10U WebClaimSigningCA1 0 U Lens10U Truepic1 0 UUS0 240130153536Z 250129153535Z0V1 0 UUS10 U OpenAI10U ChatGPT1$0"U Truepic Lens CLI in ChatGPT0Y0*H=*H=BnrSRPkE16kh^׷Pd퓥j^ ^= '?6%g00 U00U#0ZkfӔA} {]sKK0M+A0?0=+01http://va.truepic.com/ejbca/publicweb/status/ocsp0U% 0 +0U dݯ^2Lr ohs0U0  *H  H|ڭqAcV2Fp' t r:g;A>=4 C$ln-~meMnZ# *A.{=nsH $ xηpGzKx$Y~0z0bỉP:_҂(0  *H  0?10 U RootCA1 0 U Lens10U Truepic1 0 UUS0 211209203946Z 261208203945Z0J10U WebClaimSigningCA1 0 U Lens10U Truepic1 0 UUS0"0  *H 0 çPkjr3eA`(k "ŧ (b; yeyXɭBa]CPoAl%] i*+68k?~mPw&G8JK="?Ro;^9t.o#%3`s߽> 9.wNBļ-A"TL\U݄@hi䲻JKu]s,Dfa'qP#!Q6Vx UtS30IL#7<>IoLlH{Qߌ|i21&! Zp6)om )xUa*{P\Db݂'cpadY|X@+U9n?QUE|Ik0\˚wRC?E8y>jsilver-platter-0.5.44/man/0000755000000000000000000000000014721061524012264 5ustar00silver-platter-0.5.44/py/0000755000000000000000000000000014721061524012141 5ustar00silver-platter-0.5.44/pyproject.toml0000644000000000000000000000627114721061524014433 0ustar00[build-system] requires = ["setuptools>=61.2", "setuptools-rust"] build-backend = "setuptools.build_meta" [project] name = "silver-platter" authors = [{name = "Jelmer Vernooij", email = "jelmer@jelmer.uk"}] description = "Large scale VCS change management" readme = "README.md" license = {text = "GNU GPL v2 or later"} keywords = ["git bzr vcs github gitlab launchpad"] classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: GNU General Public License (GPL)", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Operating System :: POSIX", "Topic :: Software Development :: Version Control", ] requires-python = ">=3.9" dependencies = [ "breezy>=3.3.3", "dulwich>=0.20.23", "jinja2", "pyyaml", "ruamel.yaml", ] version = "0.5.36" [project.urls] Homepage = "https://jelmer.uk/code/silver-platter" "Bug Tracker" = "https://github.com/jelmer/silver-platter/issues" Repository = "https://github.com/jelmer/silver-platter" GitHub = "https://github.com/jelmer/silver-platter" [project.optional-dependencies] debian = [ "debmutate>=0.3", "python_debian>=0.1.48", "brz-debian", ] launchpad = ["launchpadlib"] detect-gbp-dch = ["lintian-brush"] testing = [ "testtools", "debmutate>=0.3", "python-debian", "brz-debian", ] dev = [ "ruff==0.7.4" ] [project.scripts] debian-svp = "silver_platter.debian.__main__:main" [tool.setuptools.packages.find] where = ["py"] include = ["silver_platter*"] [tool.setuptools.package-data] silver_platter = ["py.typed"] [tool.mypy] ignore_missing_imports = true [tool.ruff] line-length = 79 [tool.ruff.lint] select = [ "ANN", "D", "E", "F", "I", "UP", ] ignore = [ "ANN001", "ANN002", "ANN003", "ANN101", "ANN102", "ANN201", "ANN202", "ANN204", "ANN206", "ANN401", "D100", "D101", "D102", "D103", "D104", "D105", "D107", "D300", "D417", "E501", ] [tool.ruff.lint.pydocstyle] convention = "google" [tool.ruff.lint.isort] known-third-party = ["debian"] known-first-party = ["silver_platter"] [tool.cibuildwheel] environment = {PATH="$HOME/.cargo/bin:$PATH"} before-build = "pip install -U setuptools-rust && rustup show" # breezyshim embeds python3, which doesn't work with pypy skip = "pp* *musllinux*" [tool.cibuildwheel.linux] before-build = "if command -v yum; then yum -y install openssl-devel libtdb-devel clang libgpg-error-devel; fi && if command -v apk; then apk add openssl-dev pkgconfig tdb-dev llvm clang gpgme-dev; fi && pip install -U setuptools-rust && curl https://sh.rustup.rs -sSf | sh -s -- --profile=minimal -y && rustup show" [tool.cibuildwheel.macos] before-build = "brew install openssl && rustup target add aarch64-apple-darwin && rustup show" [tool.cibuildwheel.windows] before-build = "vcpkg install openssl" environment = {CMAKE_TOOLCHAIN_FILE="C:/vcpkg/scripts/buildsystems/vcpkg.cmake"} silver-platter-0.5.44/recipe.example.yaml0000644000000000000000000000120314721061524015272 0ustar00--- # Name of the recipe; used e.g. as part of the branch name when # creating merge requests. name: example # Command to run, in a pristine clone of the specified branch. command: example --flag # Supported modes: # - propose: create merge request # - push: Push changes to main branch # - attempt-push: Try to push changes to main branch, but create a merge # request if there are not enough permissions # (optional, defaults to attempt-push) mode: propose merge-request: commit-message: Make a change labels: - some-label description: This field contains the body of the merge request, and supports jinja2 templating. silver-platter-0.5.44/setup.py0000755000000000000000000000070514721061524013230 0ustar00#!/usr/bin/python3 import sys from setuptools import setup from setuptools_rust import Binding, RustExtension features = [] if sys.platform == "linux": features.append("debian") setup( rust_extensions=[ RustExtension( "silver_platter", "svp-py/Cargo.toml", binding=Binding.PyO3, args=["--no-default-features"], features=features + ["extension-module"], ), ], ) silver-platter-0.5.44/src/0000755000000000000000000000000014721061524012300 5ustar00silver-platter-0.5.44/svp0000755000000000000000000000163014721061524012247 0ustar00#!/usr/bin/python3 # Copyright (C) 2018 Jelmer Vernooij # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import os import sys sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) from silver_platter.__main__ import main # noqa: E402 sys.exit(main()) silver-platter-0.5.44/svp-client/0000755000000000000000000000000014721061524013575 5ustar00silver-platter-0.5.44/svp-py/0000755000000000000000000000000014721061524012747 5ustar00silver-platter-0.5.44/devnotes/codemods.rst0000644000000000000000000000565214721061524015677 0ustar00The core of silver-platter are changer commands, which get run in version control checkouts to make changes. Commands will be run in a clean VCS checkout, where they can make changes as they deem fit. Changes should ideally be committed; by default pending changes will be discarded (but silver-platter will warn about them, and --autocommit can specified). However, if commands just make changes and don't touch the VCS at all, silver-platter will function in "autocommit" mode and create a single commit on their behalf with a reasonable commit message. Flags can be specified on the command-line or in a recipe: * name (if not specified, taken from filename?) * command to run * merge proposal commit message (with jinja2 templating) * merge proposal description, markdown/plain (with jinja2 templating) * whether the command can resume * mode ('push', 'attempt-push', 'propose') - defaults to 'attempt-push' * optional propose threshold, with minimum value before merge proposals are created * whether to autocommit (defaults to true?) * optional URL to target (if different from base URL) The command should exit with code 0 when successful, and 1 otherwise. In the case of failure, the branch is discarded. If it is known that the command supports resuming, then a previous branch may be loaded if present. The SVP_RESUME environment variable will be set to a path to a JSON file with the previous runs metadata. The command is expected to import any metadata about the older changes and carry it forward. If resuming is not supported then all older changes will be discarded (and possibly made again by the command). Environment variables that will be set: * SVP_API: Silver-platter API major version number. Currently set to 1 * COMMITTER: Set to a committer identity (optional) * SVP_RESUME: Set to a file path with JSON results from the last run, if available and if --resume is enabled. * SVP_RESULT: Set to a (optional) path that should be created by the command with extra details The output JSON should include the following fields: * description: Optional one-line text description of the error or changes made * value: Optional integer with an indicator of the value of the changes made * tags: Optional list of names of tags that should be included with the change (autodetected if not specified) * context: Optional command-specific result data, made available during template expansion * target-branch-url: URL for branch to target, if different from original URL * versions: Dictionary with software used. Project name as key, version as value. Debian operations ----------------- For Debian branches, branches will be provided named according to DEP-13. The following environment variables will be set as well: * DEB_SOURCE: Source package name * DEB_UPDATE_CHANGELOG: Set to either update_changelog/leave_changelog (optional) * ALLOW_REFORMATTING: boolean indicating whether reformatting is allowed silver-platter-0.5.44/devnotes/command-line.rst0000644000000000000000000000114014721061524016431 0ustar00Command-line interface ====================== Example commands: svp run lp:brz-email /tmp/some-script.py svp run --name=blah lp:brz-email /tmp/some-script.py svp run -f some-script.yaml lp:brz-email svp hosters svp login https://github.com/ svp login https://gitlab.com/ svp login https://salsa.debian.org/ debian-svp run brz-email ./some-script.py debian-svp run -f lintian-brush.yaml samba debian-svp run -f lintian-brush.yaml --mode=propose samba debian-svp run -f lintian-brush.yaml --mode=push samba debian-svp upload-pending tdb debian-svp run -f new-upstream-release.yaml --no-build-verify tdb silver-platter-0.5.44/devnotes/design.rst0000644000000000000000000000142514721061524015345 0ustar00Releaser Tool ============= * Specify timeout for a release * Ability to manually trigger a release * Use a custom PGP key (trusted by mine) * Prometheus metrics Specify a bit of config (protobuf?) that determines: * Repository URL * Bug Database * Tag format * Timeout * NEWS file path(s) * Tarball location * Pypi location * PGP key Once we've determined we want to do a release: * Check if CI state is green * Clone master * Commit: * Update NEWS to mark the new release * Make sure version strings elsewhere are correct * Tag && sign tag * Create a tarball * Sign the tarball * Upload the tarball + SCP + pypi * Mark any news bugs in NEWS as fixed [later] * Commit: * Update NEWS and version strings for next version * Push changes to master and new tag Use silver-platter? silver-platter-0.5.44/devnotes/mp-status.rst0000644000000000000000000000206314721061524016030 0ustar00Closing Merge Proposals ======================= Merge proposals can have a number of statuses: status: Open (Work In Progress) "wip" More work is being done by the original author; changes have not been merged and the merge proposal is not ready for review. status: Closed (Merged) "merged" The change has been merged. No further actions are expected. On Launchpad, this means that the merge proposal is frozen - the branch can be reused. status: Closed (Rejected) "rejected" The branch has been rejected. Changes were not merged. The branch and merge proposal should be kept around so we don't create a new branch proposal with the same changes. Requires human follow-up. status: Closed (Obsolete) "obsolete" Can happen e.g. when the changes are made independently by somebody else. status: Open (Waiting Review) "waiting-review" The merge proposal is ready and waiting for review from a reviewer. status: Open (Waiting Follow-up) "waiting-followup" The merge proposal is waiting for the original author to follow up to comments from a reviewer. silver-platter-0.5.44/examples/candidates.yaml0000644000000000000000000000005514721061524016312 0ustar00--- - url: https://github.com/jelmer/dulwich silver-platter-0.5.44/examples/recipes/0000755000000000000000000000000014721061524014761 5ustar00silver-platter-0.5.44/examples/recipes/codespell.yaml0000644000000000000000000000046314721061524017622 0ustar00# To use this recipe, install codespell: # pip install codespell --- name: codespell command: |- echo Fix spelling errors codespell -ws -i0 -q15 -S "*.po,*.pot,AUTHORS,THANKS" exit 0 mode: propose merge-request: commit-message: Fix spelling errors in code description: Fix spelling errors in code silver-platter-0.5.44/examples/recipes/debian/0000755000000000000000000000000014721061524016203 5ustar00silver-platter-0.5.44/examples/recipes/disperse-migrate.yaml0000644000000000000000000000052114721061524021107 0ustar00# To use this recipe, install disperse: # cargo install --locked disperse --- name: disperse-migrate command: |- disperse migrate mode: propose merge-request: commit-message: Migrate disperse config to new version description: | Migrate disperse.conf (using ini format) to disperse.toml (using toml format). auto-merge: true silver-platter-0.5.44/examples/recipes/framwork.yaml0000644000000000000000000000055514721061524017502 0ustar00# Example of a trivial fix that uses a sed command to correct a typo in a file. --- name: framwork command: |- sed -i 's/framwork/framework/' README.rst echo "Fix common typo: framwork => framework" mode: propose merge-request: commit-message: Fix a typo description: markdown: |- I spotted that we commonly mistype *framework* as *framwork*. silver-platter-0.5.44/examples/recipes/isort.yaml0000644000000000000000000000046014721061524017005 0ustar00# To use this recipe, install isort: # $ pip install isort --- name: isort command: ['sh', '-c', 'isort --om -q .; echo "Sort Python import definitions with isort"'] mode: propose merge-request: commit-message: Sort Python import definitions description: markdown: Sort Python import definitions silver-platter-0.5.44/examples/recipes/patch.yaml0000644000000000000000000000043714721061524016750 0ustar00# Example of a recipe that applies a patch file to the source code. --- name: apply-patch command: |- patch -p1 < $PATCH echo "Apply patch $PATCH" mode: propose merge-request: commit-message: Apply patch $PATCH description: markdown: |- Apply the patch file $PATCH silver-platter-0.5.44/examples/recipes/python-modernize.yaml0000644000000000000000000000033314721061524021157 0ustar00# To use this recipe install modernize: # pip install modernize --- name: modernize command: python -m modernize -w . mode: propose merge-request: commit-message: Modernize Python code for eventual Python 3 migration silver-platter-0.5.44/examples/recipes/pyupgrade.yaml0000644000000000000000000000037114721061524017646 0ustar00# To use this recipe, install pyupgrade: # pip install pyupgrade --- name: pyupgrade command: 'pyupgrade --exit-zero-even-if-changed $(find . -name "test_*.py")' mode: propose merge-request: commit-message: Upgrade Python code to a modern version silver-platter-0.5.44/examples/recipes/teyit.yaml0000644000000000000000000000034214721061524017002 0ustar00# To use this recipe, install teyit: # pip install teyit --- name: teyit command: |- echo Improve unittest calls echo teyit $(find -name "test_*.py") mode: propose merge-request: commit-message: Improve unittest calls silver-platter-0.5.44/examples/recipes/debian/base.md0000644000000000000000000000012114721061524017431 0ustar00{% block runner %}{% endblock %} This merge proposal was created automatically. silver-platter-0.5.44/examples/recipes/debian/cme.yaml0000644000000000000000000000100314721061524017625 0ustar00--- # This runs the "cme fix" command, which makes a number of improvements # to Debian packages. This requires the "cme" package. # # Since CME doesn't provide an easily consumable report of the changes # it made, the commit message and merge proposal description created # are currently a bit generic and unhelpful ("Run CME"). name: cme-fix command: cme fix dpkg merge-proposal: commit-message: Run CME fix. description: |- {% extends "base.md" %} {% block runner -%} Run CME. {% endblock %} silver-platter-0.5.44/examples/recipes/debian/debianize.yaml0000644000000000000000000000035414721061524021023 0ustar00--- # Generate a Debian package for an upstream source repository # # Requires the "lintian-brush" package. name: debianize command: debianize merge-proposal: commit-message: Debianize package description: |- Debianize package. silver-platter-0.5.44/examples/recipes/debian/lintian-brush.yaml0000644000000000000000000000116614721061524021652 0ustar00--- name: lintian-fixes command: lintian-brush merge-proposal: commit-message: "Fix lintian issues: {{ ', '.join(sorted(applied)) }}" description: |- {% extends "base.md" %} {% block runner -%} {% if applied|length > 1 -%} Fix some issues reported by lintian {% endif -%} {% for entry in applied %} {% if applied|length > 1 %}* {% endif -%} {{ entry.summary }} {%- if entry.fixed_lintian_tags %} ({% for tag in entry.fixed_lintian_tags %}[{{ tag }}](https://lintian.debian.org/tags/{{ tag }}){% if not loop.last %}, {% endif %}{% endfor %}){% endif %} {% endfor -%} {% endblock -%} silver-platter-0.5.44/examples/recipes/debian/mia.yaml0000644000000000000000000000100614721061524017632 0ustar00--- # This uses the drop-mia-uploaders command from the debmutate package. # # It scans the Debian BTS for bugs filed by the MIA team, extracts # the e-mail addresses of MIA uploaders and drops those from the Uploaders # field. name: mia command: drop-mia-uploaders merge-proposal: commit-message: Remove MIA uploaders description: |- {% extends "base.md" %} {% block runner %} Remove MIA uploaders: {% for uploader in removed_uploaders %} * {{ uploader }} {% endfor %} {% endblock %} silver-platter-0.5.44/examples/recipes/debian/multiarch.yaml0000644000000000000000000000121014721061524021051 0ustar00--- name: multiarch-fixes command: apply-multiarch-hints merge-proposal: commit-message: Apply multi-arch hints description: |- {% extends "base.md" %} {% block runner %} Apply hints suggested by the multi-arch hinter. {% for entry in applied %} {% set kind = entry.link.split("#")[-1] %} * {{ entry.binary }}: {% if entry.action %}{{ entry.action }}. This fixes: {{ entry.description }}. ([{{ kind }}]({{ entry.link }})){% else %}Fix: {{ entry.description }}. ([{{ kind }}]({{ entry.link }})){% endif %} {% endfor %} These changes were suggested on https://wiki.debian.org/MultiArch/Hints. {% endblock %} silver-platter-0.5.44/examples/recipes/debian/new-upstream-release.yaml0000644000000000000000000000103014721061524023126 0ustar00--- name: new-upstream-release command: deb-new-upstream merge-proposal: commit-message: "Merge new upstream release {{ new_upstream_version }}" description: |- {% extends "base.md" %} {% block runner %} {% if role == 'pristine-tar' %} pristine-tar data for new upstream version {{ upstream_version }}. {% elif role == 'upstream' %} Import of new upstream version {{ upstream_version }}. {% elif role == 'main' %} Merge new upstream version {{ upstream_version }}. {% endif %} {% endblock %} silver-platter-0.5.44/examples/recipes/debian/new-upstream-snapshot.yaml0000644000000000000000000000104514721061524023353 0ustar00--- name: new-upstream-snapshot command: deb-new-upstream --snapshot merge-proposal: commit-message: "Merge new upstream snapshot {{ new_upstream_version }}" description: |- {% extends "base.md" %} {% block runner %} {% if role == 'pristine-tar' %} pristine-tar data for new upstream version {{ upstream_version }}. {% elif role == 'upstream' %} Import of new upstream version {{ upstream_version }}. {% elif role == 'main' %} Merge new upstream version {{ upstream_version }}. {% endif %} {% endblock %} silver-platter-0.5.44/examples/recipes/debian/orphan.yaml0000644000000000000000000000151714721061524020362 0ustar00--- name: orphan command: deb-move-orphaned proposal: commit-message: Move orphaned package to the QA team description: |- {% extends "base.md" %} {% block runner %} Move orphaned package to the QA team. {% if wnpp_bug %} For details, see the [orphan bug](https://bugs.debian.org/{{ wnpp_bug }}). {% endif %} {% if pushed and new_vcs_url %} Please move the repository from {{ old_vcs_url }} to {{ new_vcs_url }}. {% if old_vcs_url.startswith('https://salsa.debian.org/') %} If you have the salsa(1) tool installed, run: salsa fork --group={{ salsa_user }} {{ path }} {% else %} If you have the salsa(1) tool installed, run: git clone {{ old_vcs_url }} {{ package_name }} salsa --group={{ salsa_user }} push_repo {{ package_name }} {% endif %} {% endblock %} silver-platter-0.5.44/examples/recipes/debian/rrr.yaml0000644000000000000000000000044414721061524017676 0ustar00--- # This runs the deb-enable-rrr command from the debmutate package. name: rrr command: deb-enable-rrr merge-proposal: commit-message: Set the Rules-Requires-Root field. description: |- {% extends "base.md" %} {% block runner -%} Set Rules-Requires-Root. {% endblock %} silver-platter-0.5.44/examples/recipes/debian/scrub-obsolete.yaml0000644000000000000000000000036214721061524022020 0ustar00--- name: scrub-obsolete command: deb-scrub-obsolete merge-proposal: commit-message: Remove unnecessary constraints description: |- {% extends "base.md" %} {% block runner %} Remove unnecessary constraints. {% endblock %} silver-platter-0.5.44/helpers/needs-changelog-update.py0000755000000000000000000000217714721061524020042 0ustar00#!/usr/bin/python3 # Copyright (C) 2018 Jelmer Vernooij # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import argparse from breezy.branch import Branch import silver_platter # noqa: F401 from silver_platter.debian import _changelog_stats parser = argparse.ArgumentParser() parser.add_argument( "location", help="Branch location to check.", type=str, default="." ) args = parser.parse_args() branch = Branch.open(args.location) print(_changelog_stats(branch, 200)) silver-platter-0.5.44/man/debian-svp.10000644000000000000000000000537514721061524014410 0ustar00.TH DEBIAN-SVP "1" "February 2019" "debian-svp 0.0.1" "User Commands" .SH NAME debian-svp \- create and manage changes against Debian packaging branches .SH SYNOPSIS debian\-svp [\-h] [\-\-version] {run,new-upstream,upload-pending,lintian\-brush} ... .SH DESCRIPTION debian-svp is a specialized version of \&\fIsvp\fR\|(1) that automatically resolves Debian package names to the URLs of packaging branches. It also provides support for a couple of Debian-specific operations. .SS "COMMAND OVERVIEW" .TP .B debian\-svp run [\-h] [\-\-refresh] [\-\-label LABEL] [\-\-name NAME] [\-\-mode {push,attempt\-push,propose}] [\-\-dry\-run] [\-\-commit-pending {auto,yes,no}] package script Make a change by running a script. \fBURL\fR should be the URL of a repository to make changes to. Script will be run in a checkout of the URL, with the opportunity to make changes. Depending on the specified mode, the changes will be committed and pushed back to the repository at the original URL or proposed as a change to the repository at the original URL. .TP .B debian\-svp new\-upstream [\-h] [\-\-snapshot] [\-\-no\-build\-verify] [\-\-pre\-check PRE_CHECK] [\-\-dry\-run] [\-\-mode {push,attempt\-push,propose}] packages [packages ...] Create a merge proposal merging a new upstream version. The location of the upstream repository is retrieved from the \fBdebian/upstream/metadata\fR file, and the tarball is fetched using \&\fIuscan\fR\|(1). .TP .B "debian-svp upload-pending" Upload pending commits in a packaging branch. .TP .B debian\-svp lintian\-brush [\-\-fixers FIXERS] [\-\-dry\-run] [\-\-propose\-addon\-only PROPOSE_ADDON_ONLY] [\-\-pre\-check PRE_CHECK] [\-\-post\-check POST_CHECK] [\-\-build\-verify] [\-\-refresh] [\-\-committer COMMITTER] [\-\-mode {push,attempt\-push,propose}] [\-\-no\-update\-changelog] [\-\-update\-changelog] [packages [packages ...]] Create a merge proposal fixing lintian issues. .SS "optional arguments:" .TP \fB\-h\fR, \fB\-\-help\fR show this help message and exit .TP \fB\-\-version\fR show program's version number and exit .SH EXAMPLES .TP .B debian\-svp lintian\-brush \fBhttps://salsa.debian.org/python-team/packages/dulwich\fR Run \&\fIlintian\-brush\fR\|(1) on the \fBdulwich\fR package and create a merge proposal with the resulting changes. .TP .B debian\-svp lintian\-brush \fBdulwich\fR Run \&\fIlintian\-brush\fR\|(1) on the \fBdulwich\fR package and create a merge proposal with the resulting changes. .TP .B debian\-svp new\-upstream \fBdulwich\fR Create a new merge proposal merging the latest upstream version of \fBdulwich\fR into the packaging branch. .SH "SEE ALSO" \&\fIsvp\fR\|(1), \&\fIgit\fR\|(1), \&\fIbrz\fR\|(1), \&\fIlintian-brush\fR\|(1) .SH "LICENSE" GNU General Public License, version 2 or later. .SH AUTHORS Jelmer Vernooij silver-platter-0.5.44/man/svp.10000644000000000000000000000504714721061524013164 0ustar00.TH SVP "1" "February 2019" "svp 0.0.1" "User Commands" .SH NAME svp \- create and manage changes to VCS repositories .SH SYNOPSIS svp [\-h] [\-\-version] {run,hosters,login,proposals} ... .SH DESCRIPTION Silver-Platter makes it possible to contribute automatable changes to source code in a version control system. It automatically creates a local checkout of a remote repository, make user-specified changes, publish those changes on the remote hosting site and then creates a pull request. In addition to that, it can also perform basic maintenance on branches that have been proposed for merging - such as restarting them if they have conflicts due to upstream changes. .SS "COMMAND OVERVIEW" .TP .B svp run [\-\-refresh] [\-\-label LABEL] [\-\-name NAME] [\-\-mode {push,attempt\-push,propose}] [\-\-commit-pending {auto,yes,no}] [\-\-dry\-run] url script Make a change by running a script. \fBURL\fR should be the URL of a repository to make changes to. Script will be run in a checkout of the URL, with the opportunity to make changes. Depending on the specified mode, the changes will be committed and pushed back to the repository at the original URL or proposed as a change to the repository at the original URL. svp will exit 0 if no changes have been made, 1 if at least one repository has been changed and 2 in case of trouble. .TP .B svp hosters Display known hosting sites. .TP .B svp login BASE-URL Log into a new hosting site. .TP .B svp proposals [\-\-status {open,merged,closed}] Print URLs of all proposals of a specified status that are owned by the current user. .SS "optional arguments:" .TP \fB\-h\fR, \fB\-\-help\fR show this help message and exit .TP \fB\-\-version\fR show program's version number and exit .SH "SUPPORTED HOSTERS" At the moment \fBGitHub\fR, \fBLaunchpad\fR and any instances of \fBGitLab\fR are supported. .SH "EXAMPLES" .TP .B svp login \fBhttps://github.com/\fR Log in to GitHub .TP .B svp hosters List all known hosting sites .TP .B svp proposals --status merged List all merged proposals owned by the current user. .TP .B svp run --mode=attempt-push \fBgit://github.com/dulwich/dulwich\fR \fB./fix-typo.py\fR Run the script \fB./fix-typo.py\fR in a checkout of the Dulwich repository. Any changes the script makes will be pushed back to the main repository if the current user has the right permissions, and otherwise they will be proposed as a pull request. .SH "SEE ALSO" \&\fIdebian-svp\fR\|(1), \&\fIgit\fR\|(1), \&\fIbrz\fR\|(1) .SH "LICENSE" GNU General Public License, version 2 or later. .SH AUTHORS Jelmer Vernooij silver-platter-0.5.44/py/silver_platter.pyi0000644000000000000000000000510314721061524015722 0ustar00from collections.abc import Sequence from breezy.branch import Branch from breezy.controldir import ControlDirFormat, Prober from breezy.forge import Forge, MergeProposal from breezy.workingtree import WorkingTree def full_branch_url(branch: Branch) -> str: ... class Workspace: def __init__( self, main_branch: Branch | None = None, resume_branch: Branch | None = None, cached_branch: Branch | None = None, dir: str | None = None, path: str | None = None, additional_colocated_branches: list[str] | dict[str, str] | None = None, resume_branch_additional_colocated_branches: list[str] | dict[str, str] | None = None, format: str | ControlDirFormat | None = None, ) -> None: ... @classmethod def from_url(cls, url: str) -> Workspace: ... path: str base_revid: bytes main_branch: Branch | None main_branch_revid: bytes | None resume_branch: Branch | None local_tree: WorkingTree refreshed: bool def any_branch_changes(self) -> bool: ... def changes_since_base(self) -> bool: ... def changes_since_main(self) -> bool: ... def result_branches( self, ) -> Sequence[tuple[str, bytes | None, bytes | None]]: ... class EmptyMergeProposal(Exception): """Raised when a merge proposal is empty.""" class InsufficientChangesForNewProposal(Exception): """Raised when there are insufficient changes for a new proposal.""" def select_probers(vcs_type: str | None = None) -> Sequence[Prober]: ... def select_preferred_probers( vcs_type: str | None = None, ) -> Sequence[Prober]: ... def merge_conflicts( main_branch: Branch, other_branch: Branch, other_revision: bytes | None = None, ) -> bool: ... def find_existing_proposed( main_branch: Branch, forge: Forge, name: str, overwrite_unrelated: bool | None = None, owner: str | None = None, preferred_schemes: list[str] | None = None, ) -> tuple[Branch | None, bool | None, list[MergeProposal] | None]: ... class PublishResult: is_new: bool | None forge: Forge | None def publish_changes( local_branch: Branch, main_branch: Branch, mode: str, name: str, get_proposal_description, resume_branch=None, get_proposal_commit_message=None, get_proposal_title=None, forge=None, allow_create_proposal=None, labels=None, overwrite_existing=None, existing_proposal=None, reviewers=None, tags=None, derived_owner=None, allow_collaboration=None, stop_revision=None, ) -> PublishResult: ... silver-platter-0.5.44/py/tests/0000755000000000000000000000000014721061524013303 5ustar00silver-platter-0.5.44/py/tests/__init__.py0000644000000000000000000000200514721061524015411 0ustar00#!/usr/bin/python # Copyright (C) 2018 Jelmer Vernooij # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import unittest def test_suite(): names = [ "proposal", "workspace", ] module_names = [__name__ + ".test_" + name for name in names] loader = unittest.TestLoader() return loader.loadTestsFromNames(module_names) silver-platter-0.5.44/py/tests/test_proposal.py0000644000000000000000000001013114721061524016547 0ustar00#!/usr/bin/python # Copyright (C) 2019 Jelmer Vernooij # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import os from io import BytesIO from breezy.tests import TestCaseWithTransport from silver_platter import Workspace class WorkspaceTests(TestCaseWithTransport): def test_simple(self): b = self.make_branch("target") with Workspace(b, dir=self.test_dir) as ws: self.assertIsNone(ws.resume_branch) self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.changes_since_base()) ws.local_tree.commit("foo") self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.changes_since_main()) def test_with_resume(self): b = self.make_branch_and_tree("target") c = b.controldir.sprout("resume").open_workingtree() c.commit("some change") with Workspace( b.branch, resume_branch=c.branch, dir=self.test_dir ) as ws: self.assertEqual( ws.local_tree.branch.last_revision(), c.branch.last_revision() ) self.assertIs(ws.resume_branch, c.branch) self.assertTrue(ws.changes_since_main()) self.assertFalse(ws.changes_since_base()) ws.local_tree.commit("foo") self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.changes_since_base()) def test_with_resume_conflicting(self): b = self.make_branch_and_tree("target") self.build_tree_contents([("target/foo", "somecontents\n")]) b.add(["foo"]) b.commit("initial") c = b.controldir.sprout("resume").open_workingtree() self.build_tree_contents([("target/foo", "new contents in main\n")]) b.commit("add conflict in main") self.build_tree_contents([("resume/foo", "new contents in resume\n")]) c.commit("add conflict in resume") with Workspace( b.branch, resume_branch=c.branch, dir=self.test_dir ) as ws: self.assertTrue(ws.refreshed) self.assertEqual(ws.base_revid, b.branch.last_revision()) self.assertEqual( b.branch.last_revision(), ws.local_tree.branch.last_revision() ) self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.changes_since_base()) ws.local_tree.commit("foo") self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.changes_since_base()) def test_base_tree(self): b = self.make_branch_and_tree("target") cid = b.commit("some change") with Workspace(b.branch, dir=self.test_dir) as ws: ws.local_tree.commit("blah") self.assertEqual(cid, ws.base_tree.get_revision_id()) def test_show_diff(self): b = self.make_branch_and_tree("target") with Workspace(b.branch, dir=self.test_dir) as ws: self.build_tree_contents( [ ( os.path.join(ws.local_tree.basedir, "foo"), "some content\n", ) ] ) ws.local_tree.add(["foo"]) ws.local_tree.commit("blah") self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.changes_since_base()) f = BytesIO() ws.show_diff(outf=f) self.assertContainsRe( f.getvalue().decode("utf-8"), "\\+some content" ) silver-platter-0.5.44/py/tests/test_workspace.py0000644000000000000000000002220214721061524016710 0ustar00#!/usr/bin/python # Copyright (C) 2022 Jelmer Vernooij # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import os import shutil from breezy.revision import NULL_REVISION from breezy.tests import TestCaseWithTransport from silver_platter import Workspace class TestWorkspace(TestCaseWithTransport): def test_nascent(self): tree = self.make_branch_and_tree("origin") with Workspace(tree.branch, dir=self.test_dir) as ws: self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.any_branch_changes()) self.assertFalse(ws.changes_since_base()) ws.local_tree.commit("A change") self.assertEqual(ws.path, os.path.join(ws.local_tree.basedir, ".")) self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.changes_since_base()) self.assertTrue(ws.any_branch_changes()) self.assertEqual( [("", NULL_REVISION, ws.local_tree.last_revision())], ws.result_branches(), ) def test_without_main(self): with Workspace(None, dir=self.test_dir) as ws: self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.any_branch_changes()) self.assertFalse(ws.changes_since_base()) ws.local_tree.commit("A change") self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.changes_since_base()) self.assertTrue(ws.any_branch_changes()) self.assertEqual( [("", None, ws.local_tree.last_revision())], ws.result_branches(), ) def test_basic(self): tree = self.make_branch_and_tree("origin") revid1 = tree.commit("first commit") with Workspace(tree.branch, dir=self.test_dir) as ws: self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.any_branch_changes()) self.assertFalse(ws.changes_since_base()) ws.local_tree.commit("A change") self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.changes_since_base()) self.assertTrue(ws.any_branch_changes()) self.assertEqual( [("", revid1, ws.local_tree.last_revision())], ws.result_branches(), ) def test_cached_branch_up_to_date(self): tree = self.make_branch_and_tree("origin") revid1 = tree.commit("first commit") cached = tree.branch.controldir.sprout("cached") with Workspace( tree.branch, cached_branch=cached.open_branch(), dir=self.test_dir ) as ws: self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.any_branch_changes()) self.assertFalse(ws.changes_since_base()) self.assertEqual(ws.local_tree.last_revision(), revid1) def test_cached_branch_out_of_date(self): tree = self.make_branch_and_tree("origin") tree.commit("first commit") cached = tree.branch.controldir.sprout("cached") revid2 = tree.commit("first commit") with Workspace( tree.branch, cached_branch=cached.open_branch(), dir=self.test_dir ) as ws: self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.any_branch_changes()) self.assertFalse(ws.changes_since_base()) self.assertEqual(ws.local_tree.last_revision(), revid2) def commit_on_colo(self, controldir, name, message): colo_branch = controldir.create_branch("colo") colo_checkout = colo_branch.create_checkout(name) try: return colo_checkout.commit(message) finally: shutil.rmtree(name) def test_colocated(self): tree = self.make_branch_and_tree("origin") revid1 = tree.commit("main") colo_revid1 = self.commit_on_colo( tree.branch.controldir, "colo", "Another" ) self.assertEqual(tree.branch.last_revision(), revid1) with Workspace( tree.branch, dir=self.test_dir, additional_colocated_branches=["colo"], ) as ws: self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.any_branch_changes()) self.assertFalse(ws.changes_since_base()) ws.local_tree.commit("A change") self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.changes_since_base()) self.assertTrue(ws.any_branch_changes()) self.assertEqual( [ ("", revid1, ws.local_tree.last_revision()), ("colo", colo_revid1, colo_revid1), ], ws.result_branches(), ) def test_resume_continue(self): tree = self.make_branch_and_tree("origin") revid1 = tree.commit("first commit") resume = tree.branch.controldir.sprout("resume") resume_tree = resume.open_workingtree() resume_revid1 = resume_tree.commit("resume") with Workspace( tree.branch, resume_branch=resume_tree.branch, dir=self.test_dir ) as ws: self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.any_branch_changes()) self.assertFalse(ws.refreshed) self.assertFalse(ws.changes_since_base()) self.assertEqual(ws.local_tree.last_revision(), resume_revid1) self.assertEqual( [("", revid1, resume_revid1)], ws.result_branches() ) def test_resume_discard(self): tree = self.make_branch_and_tree("origin") tree.commit("first commit") resume = tree.branch.controldir.sprout("resume") revid2 = tree.commit("second commit") resume_tree = resume.open_workingtree() resume_tree.commit("resume") with Workspace( tree.branch, resume_branch=resume_tree.branch, dir=self.test_dir ) as ws: self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.any_branch_changes()) self.assertTrue(ws.refreshed) self.assertFalse(ws.changes_since_base()) self.assertEqual(ws.local_tree.last_revision(), revid2) self.assertEqual([("", revid2, revid2)], ws.result_branches()) def test_resume_continue_with_unchanged_colocated(self): tree = self.make_branch_and_tree("origin") revid1 = tree.commit("first commit") colo_revid1 = self.commit_on_colo( tree.branch.controldir, "colo", "First colo" ) resume = tree.branch.controldir.sprout("resume") resume_tree = resume.open_workingtree() resume_revid1 = resume_tree.commit("resume") with Workspace( tree.branch, resume_branch=resume_tree.branch, dir=self.test_dir, additional_colocated_branches=["colo"], ) as ws: self.assertTrue(ws.changes_since_main()) self.assertTrue(ws.any_branch_changes()) self.assertFalse(ws.refreshed) self.assertFalse(ws.changes_since_base()) self.assertEqual(ws.local_tree.last_revision(), resume_revid1) self.assertEqual( [ ("", revid1, resume_revid1), ("colo", colo_revid1, colo_revid1), ], ws.result_branches(), ) def test_resume_discard_with_unchanged_colocated(self): tree = self.make_branch_and_tree("origin") tree.commit("first commit") colo_revid1 = self.commit_on_colo( tree.branch.controldir, "colo", "First colo" ) resume = tree.branch.controldir.sprout("resume") self.commit_on_colo(resume, "colo", "First colo on resume") revid2 = tree.commit("second commit") resume_tree = resume.open_workingtree() resume_tree.commit("resume") with Workspace( tree.branch, resume_branch=resume_tree.branch, dir=self.test_dir, additional_colocated_branches=["colo"], ) as ws: self.assertFalse(ws.changes_since_main()) self.assertFalse(ws.any_branch_changes()) self.assertTrue(ws.refreshed) self.assertFalse(ws.changes_since_base()) self.assertEqual(ws.local_tree.last_revision(), revid2) self.assertEqual( [ ("", revid2, revid2), ("colo", colo_revid1, colo_revid1), ], ws.result_branches(), ) silver-platter-0.5.44/src/batch.rs0000644000000000000000000007075614721061524013746 0ustar00//! Batch management. use crate::candidates::Candidate; use crate::codemod::script_runner; use crate::proposal::DescriptionFormat; use crate::publish::{Error as PublishError, PublishResult}; use crate::recipe::Recipe; use crate::vcs::{open_branch, BranchOpenError}; use crate::workspace::Workspace; use crate::Mode; use breezyshim::branch::Branch; use breezyshim::error::Error as BrzError; use serde::Deserialize; use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; use url::Url; /// Current version of the batch format. pub const CURRENT_VERSION: u8 = 1; #[derive(Debug, serde::Serialize, serde::Deserialize)] /// Batch entry pub struct Entry { #[serde(skip)] local_path: PathBuf, /// Subpath within the local path to work on. #[serde(default, skip_serializing_if = "Option::is_none")] pub subpath: Option, /// URL of the target branch. #[serde(rename = "url")] pub target_branch_url: Option, /// Description of the work to be done. pub description: String, #[serde( rename = "commit-message", default, skip_serializing_if = "Option::is_none" )] /// Commit message for the work. pub commit_message: Option, #[serde( rename = "auto-merge", default, skip_serializing_if = "Option::is_none" )] /// Whether to automatically merge the proposal. pub auto_merge: Option, /// Mode for the work. pub mode: Mode, /// Title of the work. #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, /// Owner of the work. #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option, /// Labels for the work. #[serde(default, skip_serializing_if = "Option::is_none")] pub labels: Option>, /// Context for the work. #[serde(default, skip_serializing_if = "serde_yaml::Value::is_null")] pub context: serde_yaml::Value, #[serde( rename = "proposal-url", default, skip_serializing_if = "Option::is_none" )] /// URL of the proposal for this work. pub proposal_url: Option, } #[derive(Debug, serde::Deserialize, serde::Serialize)] /// Batch pub struct Batch { /// Format version #[serde(default)] pub version: u8, /// Recipe #[serde(deserialize_with = "deserialize_recipe")] pub recipe: Recipe, /// Batch name pub name: String, /// Work to be done in this batch. pub work: HashMap, #[serde(skip)] /// Basepath for the batch pub basepath: PathBuf, } fn deserialize_recipe<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { // Recipe can either be a PathBuf or a Recipe #[derive(serde::Deserialize)] #[serde(untagged)] enum RecipeOrPathBuf { Recipe(Recipe), PathBuf(PathBuf), } let value = RecipeOrPathBuf::deserialize(deserializer)?; match value { RecipeOrPathBuf::Recipe(recipe) => Ok(recipe), RecipeOrPathBuf::PathBuf(path) => { let file = std::fs::File::open(&path).map_err(serde::de::Error::custom)?; let recipe: Recipe = serde_yaml::from_reader(file).map_err(serde::de::Error::custom)?; Ok(recipe) } } } #[derive(Debug)] /// Batch error pub enum Error { /// Error running a script Script(crate::codemod::Error), /// Error opening a branch Vcs(crate::vcs::BranchOpenError), /// I/O error Io(std::io::Error), /// Error parsing YAML Yaml(serde_yaml::Error), /// Error with Tera Tera(tera::Error), /// Error with workspace Workspace(crate::workspace::Error), } impl From for Error { fn from(e: crate::workspace::Error) -> Self { Error::Workspace(e) } } impl From for Error { fn from(e: crate::codemod::Error) -> Self { Error::Script(e) } } impl From for Error { fn from(e: crate::vcs::BranchOpenError) -> Self { Error::Vcs(e) } } impl From for Error { fn from(e: std::io::Error) -> Self { Error::Io(e) } } impl From for Error { fn from(e: tera::Error) -> Self { Error::Tera(e) } } impl From for Error { fn from(e: serde_yaml::Error) -> Self { Error::Yaml(e) } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Error::Vcs(e) => write!(f, "VCS error: {}", e), Error::Script(e) => write!(f, "Script error: {}", e), Error::Io(e) => write!(f, "I/O error: {}", e), Error::Yaml(e) => write!(f, "YAML error: {}", e), Error::Tera(e) => write!(f, "Tera error: {}", e), Error::Workspace(e) => write!(f, "Workspace error: {}", e), } } } impl Entry { /// Create a new batch entry from a recipe. pub fn from_recipe( recipe: &Recipe, basepath: &Path, url: &Url, subpath: &Path, default_mode: Option, extra_env: Option>, ) -> Result { if !basepath.exists() { std::fs::create_dir_all(basepath)?; } let basepath = basepath.canonicalize().unwrap(); let main_branch = match open_branch(url, None, None, None) { Ok(branch) => branch, Err(e) => return Err(Error::Vcs(e)), }; let ws = Workspace::builder() .main_branch(main_branch) .path(basepath.to_path_buf()) .build()?; log::info!( "Making changes to {}", ws.main_branch().unwrap().get_user_url() ); let result = match script_runner( ws.local_tree(), recipe .command .as_ref() .unwrap() .argv() .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), subpath, recipe.commit_pending, None, None, extra_env, std::process::Stdio::inherit(), ) { Ok(result) => result, Err(e) => return Err(Error::Script(e)), }; let tera_context: tera::Context = tera::Context::from_value( result .context .clone() .unwrap_or_else(|| serde_json::json!({})), ) .unwrap(); let target_branch_url = match result.target_branch_url { Some(url) => Some(url), None => Some(url.clone()), }; let description = if let Some(description) = result.description { description } else if let Some(ref mr) = recipe.merge_request { mr.render_description(DescriptionFormat::Markdown, &tera_context)? .unwrap() } else { panic!("No description provided"); }; let commit_message = if let Some(commit_message) = result.commit_message { Some(commit_message) } else if let Some(ref mr) = recipe.merge_request { mr.render_commit_message(&tera_context)? } else { None }; let title = if let Some(title) = result.title { Some(title) } else if let Some(ref mr) = recipe.merge_request { mr.render_title(&tera_context)? } else { None }; let mode = recipe.mode.or(default_mode).unwrap_or_default(); let labels = recipe.labels.clone(); let context = result.context; let auto_merge = recipe.merge_request.as_ref().and_then(|mr| mr.auto_merge); let owner = None; Ok(Entry { local_path: basepath.to_path_buf(), subpath: Some(subpath.to_owned()), target_branch_url, description, commit_message, mode, owner, title, labels, auto_merge, proposal_url: None, context: serde_yaml::from_str( context .unwrap_or(serde_json::Value::Null) .to_string() .as_str(), ) .unwrap(), }) } /// Return the status of this entry pub fn status(&self) -> Status { if let Some(proposal_url) = self.proposal_url.as_ref() { let proposal = breezyshim::forge::get_proposal_by_url(proposal_url).unwrap(); if proposal.is_merged().unwrap() { Status::Merged(proposal_url.clone()) } else if proposal.is_closed().unwrap() { Status::Closed(proposal_url.clone()) } else { Status::Open(proposal_url.clone()) } } else { Status::NotPublished() } } /// Get the local working tree for this entry. pub fn working_tree(&self) -> Result { breezyshim::workingtree::open(&self.local_path) } /// Get the target branch for this entry. pub fn target_branch(&self) -> Result, BranchOpenError> { open_branch(self.target_branch_url.as_ref().unwrap(), None, None, None) } /// Get the local branch for this entry. pub fn local_branch(&self) -> Result, BranchOpenError> { let url = match url::Url::from_directory_path(&self.local_path) { Ok(url) => url, Err(_) => { return Err(BranchOpenError::Other(format!( "Invalid URL: {}", self.local_path.display() ))); } }; open_branch(&url, None, None, None) } /// Refresh the changes for this entry. pub fn refresh( &mut self, recipe: &Recipe, extra_env: Option>, ) -> Result<(), Error> { let url = self.target_branch_url.as_ref().unwrap(); let main_branch = match open_branch(url, None, None, None) { Ok(branch) => branch, Err(e) => return Err(Error::Vcs(e)), }; let ws = Workspace::builder() .main_branch(main_branch) .path(self.local_path.clone()) .build()?; log::info!( "Making changes to {}", ws.main_branch().unwrap().get_user_url() ); assert_eq!( ws.main_branch().unwrap().last_revision(), ws.local_tree().last_revision().unwrap() ); let result = match script_runner( ws.local_tree(), recipe .command .as_ref() .unwrap() .argv() .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), self.subpath.as_deref().unwrap_or_else(|| Path::new("")), recipe.commit_pending, None, None, extra_env, std::process::Stdio::inherit(), ) { Ok(result) => result, Err(e) => return Err(Error::Script(e)), }; let tera_context: tera::Context = tera::Context::from_value( result .context .clone() .unwrap_or_else(|| serde_json::json!({})), ) .unwrap(); let target_branch_url = match result.target_branch_url { Some(url) => Some(url), None => Some(url.clone()), }; let description = if let Some(description) = result.description { description } else if let Some(ref mr) = recipe.merge_request { mr.render_description(DescriptionFormat::Markdown, &tera_context)? .unwrap() } else { panic!("No description provided"); }; let commit_message = if let Some(commit_message) = result.commit_message { Some(commit_message) } else if let Some(ref mr) = recipe.merge_request { mr.render_commit_message(&tera_context)? } else { None }; let title = if let Some(title) = result.title { Some(title) } else if let Some(ref mr) = recipe.merge_request { mr.render_title(&tera_context)? } else { None }; let mode = recipe.mode.unwrap_or_default(); let labels = recipe.labels.clone(); let context = result.context; let auto_merge = recipe.merge_request.as_ref().and_then(|mr| mr.auto_merge); let owner = None; self.target_branch_url = target_branch_url; self.description = description; self.commit_message = commit_message; self.mode = mode; self.owner = owner; self.title = title; self.labels = labels; self.auto_merge = auto_merge; self.context = serde_yaml::from_str( context .unwrap_or(serde_json::Value::Null) .to_string() .as_str(), ) .unwrap(); Ok(()) } /// Publish this entry pub fn publish( &mut self, batch_name: &str, refresh: bool, overwrite: Option, ) -> Result { let target_branch_url = match self.target_branch_url.as_ref() { Some(url) => url, None => { return Err(PublishError::NoTargetBranch); } }; let result = publish_one( target_branch_url, &self.working_tree().unwrap(), batch_name, self.mode, self.proposal_url.as_ref(), self.labels.clone(), self.owner.as_deref(), refresh, self.commit_message.as_deref(), self.title.as_deref(), Some(self.description.as_str()), overwrite.or_else(|| { if self.proposal_url.is_some() { Some(true) } else { None } }), self.auto_merge, )?; if let Some(ref proposal) = result.proposal { self.proposal_url = Some(proposal.url().unwrap()); } Ok(result) } } /// Status of a batch entry. pub enum Status { /// Merged - URL of the merge proposal. Merged(Url), /// Closed - URL of the merge proposal. Closed(Url), /// Open - URL of the merge proposal. Open(Url), /// Not published yet. NotPublished(), } impl std::fmt::Display for Status { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Status::Merged(url) => write!(f, "Merged: {}", url), Status::Closed(url) => write!(f, "{} was closed without being merged", url), Status::Open(url) => write!(f, "{} is still open", url), Status::NotPublished() => write!(f, "Not published yet"), } } } impl Batch { /// Create a batch from a recipe and a set of candidates. pub fn from_recipe<'a>( recipe: &Recipe, candidates: impl Iterator, directory: &Path, extra_env: Option>, ) -> Result { // The directory should either be empty or not exist if directory.exists() { if !directory.is_dir() { return Err(Error::Io(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "Not a directory", ))); } if let Ok(entries) = std::fs::read_dir(&directory) { if entries.count() > 0 { return Err(Error::Io(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "Directory not empty", ))); } } } else { std::fs::create_dir_all(&directory)?; } // make sure directory is an absolute path let directory = directory.canonicalize().unwrap(); let mut batch = match load_batch_metadata(&directory) { Ok(Some(batch)) => batch, Ok(None) => Batch { version: CURRENT_VERSION, recipe: recipe.clone(), name: recipe.name.clone().unwrap(), work: HashMap::new(), basepath: directory.to_path_buf(), }, Err(e) => return Err(e), }; for candidate in candidates { let basename: String = candidate.shortname(); let mut name = basename.clone(); // TODO(jelmer): Search by URL rather than by name? if let Some(entry) = batch.work.get(name.as_str()) { if entry.target_branch_url.as_ref() == Some(&candidate.url) { log::info!( "Skipping {} ({}) (already in batch)", name, candidate.url.to_string() ); continue; } } let mut work_path = directory.join(&name); let mut i = 0; while std::fs::metadata(&work_path).is_ok() { i += 1; name = format!("{}.{}", basename, i); work_path = directory.join(&name); } match Entry::from_recipe( recipe, work_path.as_ref(), &candidate.url, candidate .subpath .as_deref() .unwrap_or_else(|| Path::new("")), candidate.default_mode, extra_env.clone(), ) { Ok(entry) => { batch.work.insert(name, entry); save_batch_metadata(&directory, &batch)?; } Err(e) => { log::error!("Failed to generate batch entry for {}: {}", name, e); // Recursively remove work_path std::fs::remove_dir_all(work_path)?; continue; } } } save_batch_metadata(&directory, &batch)?; Ok(batch) } /// Get reference to a batch entry. pub fn get(&self, name: &str) -> Option<&Entry> { self.work.get(name) } /// Get a mutable reference to a batch entry. pub fn get_mut(&mut self, name: &str) -> Option<&mut Entry> { self.work.get_mut(name) } /// Returen the status of all work in the batch. pub fn status(&self) -> HashMap<&str, Status> { let mut status = HashMap::new(); for (name, entry) in self.work.iter() { status.insert(name.as_str(), entry.status()); } status } /// Remove work from the batch. pub fn remove(&mut self, name: &str) -> Result<(), Error> { self.work.remove(name); let path = self.basepath.join(name); match std::fs::remove_dir_all(&path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { log::warn!("{} ({}): already removed - {}", name, path.display(), e); Ok(()) } Err(e) => Err(Error::Io(e)), } } } /// Drop a batch entry from the given directory. pub fn drop_batch_entry(directory: &Path, name: &str) -> Result<(), Error> { let mut batch = match load_batch_metadata(directory)? { Some(batch) => batch, None => return Ok(()), }; batch.work.remove(name); match std::fs::remove_dir_all(directory.join(name)) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => { log::warn!( "{} ({}): already removed - {}", name, directory.join(name).display(), e ); } Err(e) => { return Err(Error::Io(e)); } } save_batch_metadata(directory, &batch)?; Ok(()) } /// Save batch metadata to the metadata file in the given directory. pub fn save_batch_metadata(directory: &Path, batch: &Batch) -> Result<(), Error> { let mut file = std::fs::File::create(directory.join("batch.yaml"))?; serde_yaml::to_writer(&mut file, &batch)?; file.flush()?; Ok(()) } /// Load a batch metadata from the metadata file in the given directory. pub fn load_batch_metadata(directory: &Path) -> Result, Error> { assert!(directory.is_absolute()); let file = match std::fs::File::open(directory.join("batch.yaml")) { Ok(f) => f, Err(e) => { if e.kind() == std::io::ErrorKind::NotFound { return Ok(None); } return Err(Error::Io(e)); } }; let mut batch: Batch = serde_yaml::from_reader(file)?; batch.basepath = directory.to_path_buf(); // Set local path for entries for (key, entry) in batch.work.iter_mut() { entry.local_path = directory.join(key); } Ok(Some(batch)) } /// Publish a single batch entry. fn publish_one( url: &url::Url, local_tree: &breezyshim::tree::WorkingTree, batch_name: &str, mode: Mode, existing_proposal_url: Option<&url::Url>, labels: Option>, derived_owner: Option<&str>, refresh: bool, commit_message: Option<&str>, title: Option<&str>, description: Option<&str>, mut overwrite: Option, auto_merge: Option, ) -> Result { let main_branch = match crate::vcs::open_branch(url, None, None, None) { Ok(b) => b, Err(e) => { log::error!("{}: {}", url, e); return Err(e.into()); } }; let (forge, existing_proposal, mut resume_branch) = match breezyshim::forge::get_forge(main_branch.as_ref()) { Ok(f) => { let (existing_proposal, resume_branch) = if let Some(existing_proposal_url) = existing_proposal_url { let existing_proposal = f.get_proposal_by_url(existing_proposal_url).unwrap(); let resume_branch_url = existing_proposal.get_source_branch_url().unwrap().unwrap(); let (resume_branch_url, params) = breezyshim::urlutils::split_segment_parameters(&resume_branch_url); let resume_branch_name = params.get("branch"); let resume_branch = match crate::vcs::open_branch( &resume_branch_url, None, None, resume_branch_name.map(|x| x.as_str()), ) { Ok(b) => b, Err(e) => { log::error!("{} {:?}: {}", resume_branch_url, resume_branch_name, e); return Err(e.into()); } }; (Some(existing_proposal), Some(resume_branch)) } else { (None, None) }; (Some(f), existing_proposal, resume_branch) } Err(BrzError::UnsupportedForge(e)) => { if mode != Mode::Push { return Err(BrzError::UnsupportedForge(e).into()); } // We can't figure out what branch to resume from when there's no forge // that can tell us. log::warn!( "Unsupported forge ({}), will attempt to push to {}", e, crate::vcs::full_branch_url(main_branch.as_ref()), ); (None, None, None) } Err(e) => { log::error!("{}: {}", url, e); return Err(e.into()); } }; if refresh { if resume_branch.is_some() { overwrite = Some(true); } resume_branch = None; } if let Some(ref existing_proposal) = existing_proposal { log::info!("Updating {}", existing_proposal.url().unwrap()); } let local_branch = local_tree.branch(); crate::publish::enable_tag_pushing(local_branch.as_ref()).unwrap(); let publish_result = match crate::publish::publish_changes( local_branch.as_ref(), main_branch.as_ref(), resume_branch.as_ref().map(|b| b.as_ref()), mode, batch_name, |_df, _ep| description.unwrap().to_string(), Some(|_ep: Option<&crate::proposal::MergeProposal>| commit_message.map(|s| s.to_string())), Some(|_ep: Option<&crate::proposal::MergeProposal>| title.map(|s| s.to_string())), forge.as_ref(), Some(true), None, overwrite, existing_proposal, labels, None, derived_owner, None, None, auto_merge, ) { Ok(r) => r, Err(e) => match e { PublishError::UnsupportedForge(ref url) => { log::error!("No known supported forge for {}. Run 'svp login'?", url); return Err(e); } PublishError::InsufficientChangesForNewProposal => { log::info!("Insufficient changes for a new merge proposal"); return Err(e); } PublishError::DivergedBranches() => { if resume_branch.is_none() { return Err(PublishError::UnrelatedBranchExists); } log::warn!("Branch exists that has diverged"); return Err(e); } PublishError::ForgeLoginRequired => { log::error!( "Credentials for hosting site at {} missing. Run 'svp login'?", url ); return Err(e); } _ => { log::error!("Failed to publish: {}", e); return Err(e); } }, }; if let Some(ref proposal) = publish_result.proposal { if publish_result.is_new == Some(true) { log::info!("Merge proposal created."); } else { log::info!("Merge proposal updated.") } log::info!("URL: {}", proposal.url().unwrap()); log::info!( "Description: {}", proposal.get_description().unwrap().unwrap() ); } Ok(publish_result) } #[cfg(test)] mod tests { #[test] fn test_entry_from_recipe() { let td = tempfile::tempdir().unwrap(); let remote = tempfile::tempdir().unwrap(); breezyshim::controldir::create_branch_convenience( &url::Url::from_directory_path(remote.path()).unwrap(), None, &breezyshim::controldir::ControlDirFormat::default(), ) .unwrap(); let recipe = crate::recipe::RecipeBuilder::new(); let recipe = recipe .shell("echo hello > hello.txt; echo hello".to_owned()) .build(); let entry = crate::batch::Entry::from_recipe( &recipe, td.path(), &url::Url::from_directory_path(&remote.path()).unwrap(), &std::path::Path::new(""), None, None, ) .unwrap(); assert_eq!(entry.description, "hello\n"); } #[test] fn test_batch_from_recipe() { let td = tempfile::tempdir().unwrap(); let remote = tempfile::tempdir().unwrap(); breezyshim::controldir::create_branch_convenience( &url::Url::from_directory_path(remote.path()).unwrap(), None, &breezyshim::controldir::ControlDirFormat::default(), ) .unwrap(); let recipe = crate::recipe::RecipeBuilder::new(); let recipe = recipe .name("hello".to_owned()) .shell("echo hello > hello.txt; echo hello".to_owned()) .build(); let candidate = crate::candidates::Candidate { url: url::Url::from_directory_path(&remote.path()).unwrap(), subpath: None, default_mode: None, branch: None, name: Some("foo".to_owned()), }; let batch = crate::batch::Batch::from_recipe(&recipe, std::iter::once(&candidate), td.path(), None) .unwrap(); assert_eq!(batch.work.len(), 1); let entry = batch.work.get("foo").unwrap(); assert_eq!(entry.description, "hello\n"); } } silver-platter-0.5.44/src/bin/0000755000000000000000000000000014721061524013050 5ustar00silver-platter-0.5.44/src/candidates.rs0000644000000000000000000000676514721061524014763 0ustar00//! Candidates for packages. use crate::Mode; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] /// A candidate for a package. pub struct Candidate { /// The URL of the repository. pub url: url::Url, /// The name of the package. pub name: Option, /// The branch to use. pub branch: Option, /// The subpath to use. pub subpath: Option, #[serde(rename = "default-mode")] /// The default mode to use. pub default_mode: Option, } impl Candidate { /// Return the short name of the candidate. pub fn shortname(&self) -> String { self.name.as_ref().map(|s| s.clone()).unwrap_or_else(|| { self.url .path_segments() .and_then(|segments| segments.last()) .unwrap_or("unknown") .to_string() }) } } #[derive(Debug, Clone, Default)] /// Candidates pub struct Candidates(Vec); impl Candidates { /// Load packages from a file pub fn from_path(path: &std::path::Path) -> std::io::Result { let f = std::fs::File::open(path)?; let candidates: Vec = serde_yaml::from_reader(f).unwrap(); Ok(Self(candidates)) } /// Return a slice of the candidates. pub fn candidates(&self) -> &[Candidate] { self.0.as_slice() } /// Return an iterator over the candidates. pub fn iter(&self) -> impl Iterator { self.0.iter() } /// Create an empty Candidates object. pub fn new() -> Self { Self(Vec::new()) } } impl TryFrom for Candidates { type Error = serde_yaml::Error; fn try_from(yaml: serde_yaml::Value) -> Result { Ok(Self(serde_yaml::from_value(yaml)?)) } } impl From> for Candidates { fn from(candidates: Vec) -> Self { Self(candidates) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_read() { let td = tempfile::tempdir().unwrap(); let path = td.path().join("candidates.yaml"); std::fs::write( &path, r#"--- - url: https://github.com/jelmer/dulwich - name: samba url: https://git.samba.org/samba.git "#, ) .unwrap(); let candidates = Candidates::from_path(&path).unwrap(); assert_eq!(candidates.candidates().len(), 2); assert_eq!( candidates.candidates()[0].url, url::Url::parse("https://github.com/jelmer/dulwich").unwrap() ); assert_eq!( candidates.candidates()[1].url, url::Url::parse("https://git.samba.org/samba.git").unwrap() ); assert_eq!(candidates.candidates()[1].name, Some("samba".to_string())); } #[test] fn test_shortname() { let candidate = Candidate { url: url::Url::parse("https://github.com/jelmer/dulwich").unwrap(), name: None, branch: None, subpath: None, default_mode: None, }; assert_eq!(candidate.shortname(), "dulwich"); } #[test] fn test_shortname_stored() { let candidate = Candidate { url: url::Url::parse("https://github.com/jelmer/dulwich").unwrap(), name: Some("foo".to_string()), branch: None, subpath: None, default_mode: None, }; assert_eq!(candidate.shortname(), "foo"); } } silver-platter-0.5.44/src/checks.rs0000644000000000000000000000373414721061524014115 0ustar00//! Check if the package should be uploaded use breezyshim::tree::WorkingTree; use breezyshim::RevisionId; use std::collections::HashMap; use std::error::Error; use std::fmt; use std::process::Command; #[derive(Debug)] /// The pre check failed pub struct PreCheckFailed; impl fmt::Display for PreCheckFailed { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Pre-check failed") } } impl Error for PreCheckFailed {} /// Run check to see if the package should be uploaded pub fn run_pre_check(tree: WorkingTree, script: &str) -> Result<(), PreCheckFailed> { let path = tree.abspath(std::path::Path::new("")).unwrap(); let status = Command::new("sh") .arg("-c") .arg(script) .current_dir(path) .status(); match status { Ok(status) => { if status.code().unwrap() != 0 { Err(PreCheckFailed) } else { Ok(()) } } Err(_) => Err(PreCheckFailed), } } #[derive(Debug)] /// The post check failed pub struct PostCheckFailed; impl fmt::Display for PostCheckFailed { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Post-check failed") } } impl Error for PostCheckFailed {} /// Post-build check if the package should be uploaded pub fn run_post_check( tree: WorkingTree, script: &str, since_revid: &RevisionId, ) -> Result<(), PostCheckFailed> { let mut env_vars = HashMap::new(); env_vars.insert("SINCE_REVID", since_revid.to_string()); let path = tree.abspath(std::path::Path::new("")).unwrap(); let status = Command::new("sh") .arg("-c") .arg(script) .current_dir(path) .envs(&env_vars) .status(); match status { Ok(status) => { if status.code().unwrap() != 0 { Err(PostCheckFailed) } else { Ok(()) } } Err(_) => Err(PostCheckFailed), } } silver-platter-0.5.44/src/codemod.rs0000644000000000000000000003715114721061524014267 0ustar00//! Codemod use breezyshim::error::Error as BrzError; use breezyshim::tree::WorkingTree; use breezyshim::RevisionId; use std::collections::HashMap; use url::Url; #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] /// Command result pub struct CommandResult { /// Value pub value: Option, /// Context pub context: Option, /// Description pub description: Option, /// Serialized context pub serialized_context: Option, /// Commit message pub commit_message: Option, /// Title pub title: Option, /// Tags pub tags: Vec<(String, Option)>, /// Target branch URL pub target_branch_url: Option, /// Old revision pub old_revision: RevisionId, /// New revision pub new_revision: RevisionId, } impl crate::CodemodResult for CommandResult { fn context(&self) -> serde_json::Value { self.context.clone().unwrap_or_default() } fn value(&self) -> Option { self.value } fn target_branch_url(&self) -> Option { self.target_branch_url.clone() } fn description(&self) -> Option { self.description.clone() } fn tags(&self) -> Vec<(String, Option)> { self.tags.clone() } } impl From<&CommandResult> for DetailedSuccess { fn from(r: &CommandResult) -> Self { DetailedSuccess { value: r.value, context: r.context.clone(), description: r.description.clone(), commit_message: r.commit_message.clone(), title: r.title.clone(), serialized_context: r.serialized_context.clone(), tags: Some( r.tags .iter() .map(|(k, v)| (k.clone(), v.as_ref().map(|v| v.to_string()))) .collect(), ), target_branch_url: r.target_branch_url.clone(), } } } #[derive(Debug, serde::Deserialize, serde::Serialize, Default)] struct DetailedSuccess { value: Option, context: Option, description: Option, serialized_context: Option, #[serde(rename = "commit-message")] commit_message: Option, title: Option, tags: Option)>>, #[serde(rename = "target-branch-url")] target_branch_url: Option, } #[derive(Debug)] /// Error while running codemod pub enum Error { /// Script made no changes ScriptMadeNoChanges, /// Script was not found ScriptNotFound, /// The script failed with a specific exit code ExitCode(i32), /// Detailed failure Detailed(DetailedFailure), /// I/O error Io(std::io::Error), /// JSON error Json(serde_json::Error), /// UTF-8 error Utf8(std::string::FromUtf8Error), /// Other error Other(String), } impl From for Error { fn from(e: std::io::Error) -> Self { Error::Io(e) } } impl From for Error { fn from(e: serde_json::Error) -> Self { Error::Json(e) } } impl From for Error { fn from(e: std::string::FromUtf8Error) -> Self { Error::Utf8(e) } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Error::ScriptMadeNoChanges => write!(f, "Script made no changes"), Error::ScriptNotFound => write!(f, "Script not found"), Error::ExitCode(code) => write!(f, "Script exited with code {}", code), Error::Detailed(d) => write!(f, "Script failed: {:?}", d), Error::Io(e) => write!(f, "Command failed: {}", e), Error::Json(e) => write!(f, "JSON error: {}", e), Error::Utf8(e) => write!(f, "UTF-8 error: {}", e), Error::Other(s) => write!(f, "{}", s), } } } impl std::error::Error for Error {} #[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)] /// Detailed failure information pub struct DetailedFailure { /// Result code pub result_code: String, /// Description of the failure pub description: Option, /// Stage at which the failure occurred pub stage: Option>, /// Additional details pub details: Option, } impl std::fmt::Display for DetailedFailure { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "Script failed: {}", self.result_code)?; if let Some(description) = &self.description { write!(f, ": {}", description)?; } if let Some(stage) = &self.stage { write!(f, " (stage: {})", stage.join(" "))?; } if let Some(details) = &self.details { write!(f, ": {:?}", details)?; } Ok(()) } } /// Run a script in a tree and commit the result. /// /// This ignores newly added files. /// /// # Arguments /// /// - `local_tree`: Local tree to run script in /// - `subpath`: Subpath to run script in /// - `script`: Script to run /// - `commit_pending`: Whether to commit pending changes pub fn script_runner( local_tree: &WorkingTree, script: &[&str], subpath: &std::path::Path, commit_pending: crate::CommitPending, resume_metadata: Option<&serde_json::Value>, committer: Option<&str>, extra_env: Option>, stderr: std::process::Stdio, ) -> Result { let mut env = std::env::vars().collect::>(); if let Some(extra_env) = extra_env { for (k, v) in extra_env { env.insert(k, v); } } env.insert("SVP_API".to_string(), "1".to_string()); let last_revision = local_tree.last_revision().unwrap(); let mut orig_tags = local_tree.get_tag_dict().unwrap(); let td = tempfile::tempdir()?; let result_path = td.path().join("result.json"); env.insert( "SVP_RESULT".to_string(), result_path.to_string_lossy().to_string(), ); if let Some(resume_metadata) = resume_metadata { let resume_path = td.path().join("resume.json"); env.insert( "SVP_RESUME".to_string(), resume_path.to_string_lossy().to_string(), ); let w = std::fs::File::create(&resume_path)?; serde_json::to_writer(w, &resume_metadata)?; } let mut command = std::process::Command::new(script[0]); command.args(&script[1..]); command.envs(env); command.stdout(std::process::Stdio::piped()); command.stderr(stderr); command.current_dir(local_tree.abspath(subpath).unwrap()); let ret = match command.output() { Ok(ret) => ret, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Err(Error::ScriptNotFound); } Err(e) => { return Err(Error::Io(e)); } }; if !ret.status.success() { return Err(match std::fs::read_to_string(&result_path) { Ok(result) => { let result: DetailedFailure = serde_json::from_str(&result)?; Error::Detailed(result) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Error::ExitCode(ret.status.code().unwrap_or(1)) } Err(_) => Error::ExitCode(ret.status.code().unwrap_or(1)), }); } // Open result_path, read metadata let mut result: DetailedSuccess = match std::fs::read_to_string(&result_path) { Ok(result) => serde_json::from_str(&result)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => DetailedSuccess::default(), Err(e) => return Err(e.into()), }; if result.description.is_none() { result.description = Some(String::from_utf8(ret.stdout)?); } let mut new_revision = local_tree.last_revision().unwrap(); let tags: Vec<(String, Option)> = if let Some(tags) = result.tags { tags.into_iter() .map(|(n, v)| (n, v.map(|v| RevisionId::from(v.as_bytes().to_vec())))) .collect() } else { let mut tags = local_tree .get_tag_dict() .unwrap() .into_iter() .filter_map(|(n, v)| { if orig_tags.remove(n.as_str()).as_ref() != Some(&v) { Some((n, Some(v))) } else { None } }) .collect::>(); tags.extend(orig_tags.into_keys().map(|n| (n, None))); tags }; let commit_pending = match commit_pending { crate::CommitPending::Auto => { // Automatically commit pending changes if the script did not // touch the branch last_revision == new_revision } crate::CommitPending::Yes => true, crate::CommitPending::No => false, }; if commit_pending { local_tree .smart_add(&[local_tree.abspath(subpath).unwrap().as_path()]) .unwrap(); let mut builder = local_tree .build_commit() .message(result.description.as_ref().unwrap()) .allow_pointless(false); if let Some(committer) = committer { builder = builder.committer(committer); } new_revision = match builder.commit() { Ok(rev) => rev, Err(BrzError::PointlessCommit) => { // No changes last_revision.clone() } Err(e) => return Err(Error::Other(format!("Failed to commit changes: {}", e))), }; } if new_revision == last_revision { return Err(Error::ScriptMadeNoChanges); } let old_revision = last_revision; let new_revision = local_tree.last_revision().unwrap(); Ok(CommandResult { old_revision, new_revision, tags, description: result.description, value: result.value, context: result.context, commit_message: result.commit_message, title: result.title, serialized_context: result.serialized_context, target_branch_url: result.target_branch_url, }) } #[cfg(test)] mod script_runner_tests { use breezyshim::tree::MutableTree; use breezyshim::controldir::create_standalone_workingtree; fn make_executable(script_path: &std::path::Path) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; // Make script.sh executable let mut perm = std::fs::metadata(script_path).unwrap().permissions(); perm.set_mode(0o755); std::fs::set_permissions(script_path, perm).unwrap(); } } #[test] fn test_no_api() { let td = tempfile::tempdir().unwrap(); let d = td.path().join("t"); let tree = create_standalone_workingtree(&d, "bzr").unwrap(); let script_path = td.path().join("script.sh"); std::fs::write( &script_path, r#"#!/bin/sh echo foo > bar echo Did a thing "#, ) .unwrap(); make_executable(&script_path); std::fs::write(d.join("bar"), "bar").unwrap(); tree.add(&[std::path::Path::new("bar")]).unwrap(); let old_revid = tree.build_commit().message("initial").commit().unwrap(); let script_path_str = script_path.to_str().unwrap(); let result = super::script_runner( &tree, &[script_path_str], std::path::Path::new(""), crate::CommitPending::Auto, None, Some("Joe Example "), None, std::process::Stdio::null(), ) .unwrap(); assert!(!tree.has_changes().unwrap()); assert_eq!(result.old_revision, old_revid); assert_eq!(result.new_revision, tree.last_revision().unwrap()); assert_eq!(result.description.as_deref().unwrap(), "Did a thing\n"); std::mem::drop(td); } #[test] fn test_api() { let td = tempfile::tempdir().unwrap(); let d = td.path().join("t"); let tree = create_standalone_workingtree(&d, "bzr").unwrap(); let script_path = td.path().join("script.sh"); std::fs::write( &script_path, r#"#!/bin/sh echo foo > bar echo '{"description": "Did a thing", "code": "success"}' > $SVP_RESULT "#, ) .unwrap(); make_executable(&script_path); std::fs::write(d.join("bar"), "bar").unwrap(); tree.add(&[std::path::Path::new("bar")]).unwrap(); let old_revid = tree.build_commit().message("initial").commit().unwrap(); let script_path_str = script_path.to_str().unwrap(); let result = super::script_runner( &tree, &[script_path_str], std::path::Path::new(""), crate::CommitPending::Auto, None, Some("Joe Example "), None, std::process::Stdio::null(), ) .unwrap(); assert!(!tree.has_changes().unwrap()); assert_eq!(result.old_revision, old_revid); assert_eq!(result.new_revision, tree.last_revision().unwrap()); assert_eq!(result.description.as_deref().unwrap(), "Did a thing"); std::mem::drop(td); } #[test] fn test_new_file() { let td = tempfile::tempdir().unwrap(); let d = td.path().join("t"); let tree = create_standalone_workingtree(&d, "bzr").unwrap(); let script_path = d.join("script.sh"); std::fs::write( &script_path, r#"#!/bin/sh echo foo > bar echo Did a thing "#, ) .unwrap(); make_executable(&script_path); std::fs::write(d.join("bar"), "initial").unwrap(); tree.add(&[std::path::Path::new("bar")]).unwrap(); let old_revid = tree.build_commit().message("initial").commit().unwrap(); let script_path_str = script_path.to_str().unwrap(); let result = super::script_runner( &tree, &[script_path_str], std::path::Path::new(""), crate::CommitPending::Auto, None, Some("Joe Example "), None, std::process::Stdio::null(), ) .unwrap(); assert!(!tree.has_changes().unwrap()); assert_eq!(result.old_revision, old_revid); assert_eq!(result.new_revision, tree.last_revision().unwrap()); assert_eq!(result.description.as_deref().unwrap(), "Did a thing\n"); std::mem::drop(td); } #[test] fn test_no_changes() { let td = tempfile::tempdir().unwrap(); let d = td.path().join("t"); let tree = create_standalone_workingtree(&d, &breezyshim::controldir::ControlDirFormat::default()) .unwrap(); let script_path = td.path().join("script.sh"); std::fs::write( &script_path, r#"#!/bin/sh echo Did a thing "#, ) .unwrap(); make_executable(&script_path); tree.build_commit() .message("initial") .allow_pointless(true) .commit() .unwrap(); let script_path_str = script_path.to_str().unwrap(); let err = super::script_runner( &tree, &[script_path_str], std::path::Path::new(""), crate::CommitPending::Yes, None, Some("Joe Example "), None, std::process::Stdio::null(), ) .unwrap_err(); assert!(!tree.has_changes().unwrap()); assert!(matches!(err, super::Error::ScriptMadeNoChanges)); std::mem::drop(td); } } silver-platter-0.5.44/src/debian/0000755000000000000000000000000014721061524013522 5ustar00silver-platter-0.5.44/src/lib.rs0000644000000000000000000001362414721061524013422 0ustar00//! # Silver-Platter //! //! Silver-Platter makes it possible to contribute automatable changes to source //! code in a version control system //! ([codemods](https://github.com/jelmer/awesome-codemods)). //! //! It automatically creates a local checkout of a remote repository, //! makes user-specified changes, publishes those changes on the remote hosting //! site and then creates a pull request. //! //! In addition to that, it can also perform basic maintenance on branches //! that have been proposed for merging - such as restarting them if they //! have conflicts due to upstream changes. #![deny(missing_docs)] pub mod batch; pub mod candidates; pub mod checks; pub mod codemod; #[cfg(feature = "debian")] pub mod debian; pub mod probers; pub mod proposal; pub mod publish; pub mod recipe; pub mod run; pub mod utils; pub mod vcs; pub mod workspace; pub use breezyshim::branch::{Branch, GenericBranch}; pub use breezyshim::controldir::{ControlDir, Prober}; pub use breezyshim::forge::{Forge, MergeProposal}; pub use breezyshim::transport::Transport; pub use breezyshim::tree::WorkingTree; pub use breezyshim::RevisionId; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::path::Path; #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] /// Publish mode pub enum Mode { #[serde(rename = "push")] /// Push to the target branch Push, #[serde(rename = "propose")] /// Propose a merge Propose, #[serde(rename = "attempt-push")] #[default] /// Attempt to push to the target branch, falling back to propose if necessary AttemptPush, #[serde(rename = "push-derived")] /// Push to a branch derived from the script name PushDerived, #[serde(rename = "bts")] /// Bug tracking system Bts, } impl ToString for Mode { fn to_string(&self) -> String { match self { Mode::Push => "push".to_string(), Mode::Propose => "propose".to_string(), Mode::AttemptPush => "attempt-push".to_string(), Mode::PushDerived => "push-derived".to_string(), Mode::Bts => "bts".to_string(), } } } impl std::str::FromStr for Mode { type Err = String; fn from_str(s: &str) -> Result { match s { "push" => Ok(Mode::Push), "propose" => Ok(Mode::Propose), "attempt" | "attempt-push" => Ok(Mode::AttemptPush), "push-derived" => Ok(Mode::PushDerived), "bts" => Ok(Mode::Bts), _ => Err(format!("Unknown mode: {}", s)), } } } #[cfg(feature = "pyo3")] impl pyo3::FromPyObject<'_> for Mode { fn extract_bound(ob: &pyo3::Bound) -> pyo3::PyResult { use pyo3::prelude::*; let s: std::borrow::Cow = ob.extract()?; match s.as_ref() { "push" => Ok(Mode::Push), "propose" => Ok(Mode::Propose), "attempt-push" => Ok(Mode::AttemptPush), "push-derived" => Ok(Mode::PushDerived), "bts" => Ok(Mode::Bts), _ => Err(pyo3::exceptions::PyValueError::new_err((format!( "Unknown mode: {}", s ),))), } } } #[cfg(feature = "pyo3")] impl pyo3::ToPyObject for Mode { fn to_object(&self, py: pyo3::Python) -> pyo3::PyObject { self.to_string().to_object(py) } } /// Returns the branch name derived from a script name pub fn derived_branch_name(script: &str) -> &str { let first_word = script.split(' ').next().unwrap_or(""); let script_name = Path::new(first_word).file_stem().unwrap_or_default(); script_name.to_str().unwrap_or("") } /// Policy on whether to commit pending changes #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum CommitPending { /// Automatically determine pending changes #[default] Auto, /// Commit pending changes Yes, /// Don't commit pending changes No, } impl std::str::FromStr for CommitPending { type Err = String; fn from_str(s: &str) -> Result { match s { "auto" => Ok(CommitPending::Auto), "yes" => Ok(CommitPending::Yes), "no" => Ok(CommitPending::No), _ => Err(format!("Unknown commit-pending value: {}", s)), } } } impl Serialize for CommitPending { fn serialize(&self, serializer: S) -> Result where S: Serializer, { match *self { CommitPending::Auto => serializer.serialize_none(), CommitPending::Yes => serializer.serialize_bool(true), CommitPending::No => serializer.serialize_bool(false), } } } impl<'de> Deserialize<'de> for CommitPending { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let opt: Option = Option::deserialize(deserializer)?; Ok(match opt { None => CommitPending::Auto, Some(true) => CommitPending::Yes, Some(false) => CommitPending::No, }) } } impl CommitPending { /// Returns whether the policy is to commit pending changes pub fn is_default(&self) -> bool { *self == CommitPending::Auto } } /// The result of a codemod pub trait CodemodResult { /// Context fn context(&self) -> serde_json::Value; /// Returns the value of the result fn value(&self) -> Option; /// Returns the URL of the target branch fn target_branch_url(&self) -> Option; /// Returns the description of the result fn description(&self) -> Option; /// Returns the tags of the result fn tags(&self) -> Vec<(String, Option)>; /// Returns the context as a Tera context fn tera_context(&self) -> tera::Context { tera::Context::from_value(self.context()).unwrap() } } /// The version of the library pub const VERSION: &str = env!("CARGO_PKG_VERSION"); silver-platter-0.5.44/src/probers.rs0000644000000000000000000000430314721061524014322 0ustar00//! Selection of probers use breezyshim::controldir::Prober; /// Get a prober for a particular VCS type. pub fn get_prober(vcs_type: &str) -> Option> { match vcs_type { "bzr" => breezyshim::bazaar::RemoteBzrProber::new() .map(|prober| Box::new(prober) as Box), "git" => breezyshim::git::RemoteGitProber::new() .map(|prober| Box::new(prober) as Box), "hg" => breezyshim::mercurial::SmartHgProber::new() .map(|prober| Box::new(prober) as Box), "svn" => breezyshim::subversion::SvnRepositoryProber::new() .map(|prober| Box::new(prober) as Box), "fossil" => breezyshim::fossil::RemoteFossilProber::new() .map(|prober| Box::new(prober) as Box), "darcs" => { breezyshim::darcs::DarcsProber::new().map(|prober| Box::new(prober) as Box) } "cvs" => { breezyshim::cvs::CVSProber::new().map(|prober| Box::new(prober) as Box) } _ => None, } } /// Select all probers relevant to a particular VCS type. pub fn select_probers(vcs_type: Option<&str>) -> Vec> { if let Some(vcs_type) = vcs_type { if let Some(prober) = get_prober(vcs_type) { return vec![prober]; } vec![] } else { breezyshim::controldir::all_probers() } } /// Select probers with the given VCS type as the first prober. pub fn select_preferred_probers(vcs_type: Option<&str>) -> Vec> { let mut probers = breezyshim::controldir::all_probers(); if let Some(vcs_type) = vcs_type { if let Some(prober) = get_prober(&vcs_type.to_lowercase()) { probers.insert(0, prober); } } probers } #[cfg(test)] mod tests { #[test] fn test_probers() { let _ = super::select_probers(None); let ps = super::select_probers(Some("bzr")); assert_eq!(ps.len(), 1); } #[test] fn test_preferred_probers() { let _ = super::select_preferred_probers(None); let ps = super::select_preferred_probers(Some("bzr")); assert!(ps.len() > 1); } } silver-platter-0.5.44/src/proposal.rs0000644000000000000000000001030414721061524014503 0ustar00//! Merge proposal related functions use crate::vcs::{full_branch_url, open_branch}; use breezyshim::branch::Branch; use breezyshim::error::Error as BrzError; pub use breezyshim::forge::MergeProposal; pub use breezyshim::forge::MergeProposalStatus; use breezyshim::forge::{iter_forge_instances, Forge}; use serde::{Deserialize, Serialize}; use std::str::FromStr; use url::Url; fn instance_iter_mps( instance: Forge, statuses: Option>, ) -> impl Iterator { let statuses = statuses.unwrap_or_else(|| vec![MergeProposalStatus::All]); statuses .into_iter() .flat_map( move |status| match instance.iter_my_proposals(Some(status), None) { Ok(mps) => Some(mps), Err(BrzError::ForgeLoginRequired) => { log::warn!("Skipping forge {:?} because login is required", instance); None } Err(e) => { log::error!("Error listing merge proposals: {:?}", e); None } }, ) .flatten() } /// Iterate over all merge proposals pub fn iter_all_mps( statuses: Option>, ) -> impl Iterator { let statuses = statuses.unwrap_or_else(|| vec![MergeProposalStatus::All]); iter_forge_instances().flat_map(move |instance| { instance_iter_mps(instance.clone(), Some(statuses.clone())) .map(move |mp| (instance.clone(), mp)) }) } /// Find conflicted branches owned by the current user. /// /// # Arguments /// * `branch_name`: Branch name to search for pub fn iter_conflicted( branch_name: &str, ) -> impl Iterator< Item = ( Url, Box, String, Box, Forge, MergeProposal, bool, ), > + '_ { let mut possible_transports = vec![]; iter_all_mps(Some(vec![MergeProposalStatus::Open])).filter_map(move |(forge, mp)| { if mp.can_be_merged().unwrap() { None } else { let main_branch = open_branch( &mp.get_target_branch_url().unwrap().unwrap(), Some(&mut possible_transports), None, None, ) .unwrap(); let resume_branch = open_branch( &mp.get_source_branch_url().unwrap().unwrap(), Some(&mut possible_transports), None, None, ) .unwrap(); if resume_branch.name().as_deref() != Some(branch_name) && !(resume_branch.name().is_none() && resume_branch.get_user_url().as_str().ends_with(branch_name)) { None } else { // TODO(jelmer): Find out somehow whether we need to modify a subpath? let subpath = ""; Some(( full_branch_url(resume_branch.as_ref()), main_branch, subpath.to_string(), resume_branch, forge, mp, true, )) } } }) } #[derive(Debug, Serialize, Deserialize, Clone, Hash, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] /// Description format for merge proposals descriptions pub enum DescriptionFormat { /// Markdown format Markdown, /// HTML format Html, /// Plain text format Plain, } impl FromStr for DescriptionFormat { type Err = String; fn from_str(s: &str) -> Result { match s { "markdown" => Ok(DescriptionFormat::Markdown), "html" => Ok(DescriptionFormat::Html), "plain" => Ok(DescriptionFormat::Plain), _ => Err(format!("Unknown description format: {}", s)), } } } impl ToString for DescriptionFormat { fn to_string(&self) -> String { match self { DescriptionFormat::Markdown => "markdown".to_string(), DescriptionFormat::Html => "html".to_string(), DescriptionFormat::Plain => "plain".to_string(), } } } silver-platter-0.5.44/src/publish.rs0000644000000000000000000010115714721061524014321 0ustar00//! Publishing changes pub use crate::proposal::DescriptionFormat; use crate::vcs::open_branch; use crate::Mode; use breezyshim::branch::MemoryBranch; use breezyshim::error::Error as BrzError; use breezyshim::merge::{MergeType, Merger}; use breezyshim::{Branch, Forge, MergeProposal, RevisionId, Transport}; use std::collections::HashMap; fn _tag_selector_from_tags( tags: std::collections::HashMap, ) -> impl Fn(String) -> bool { move |tag| tags.contains_key(tag.as_str()) } /// Push derived changes pub fn push_derived_changes( local_branch: &dyn Branch, main_branch: &dyn Branch, forge: &Forge, name: &str, overwrite_existing: Option, owner: Option<&str>, tags: Option>, stop_revision: Option<&RevisionId>, ) -> Result<(Box, url::Url), BrzError> { let tags = tags.unwrap_or_default(); let (remote_branch, public_branch_url) = forge.publish_derived( local_branch, main_branch, name, overwrite_existing, owner, stop_revision, Some(Box::new(_tag_selector_from_tags(tags))), )?; Ok((remote_branch, public_branch_url)) } /// Push result pub fn push_result( local_branch: &dyn Branch, remote_branch: &dyn Branch, additional_colocated_branches: Option>, tags: Option>, stop_revision: Option<&RevisionId>, ) -> Result<(), BrzError> { let tag_selector = Box::new(_tag_selector_from_tags(tags.clone().unwrap_or_default())); local_branch.push(remote_branch, false, stop_revision, Some(tag_selector))?; for (from_branch_name, to_branch_name) in additional_colocated_branches.unwrap_or_default() { match local_branch .controldir() .open_branch(Some(from_branch_name.as_str())) { Ok(branch) => { let tag_selector = Box::new(_tag_selector_from_tags(tags.clone().unwrap_or_default())); remote_branch.controldir().push_branch( branch.as_ref(), Some(to_branch_name.as_str()), None, Some(false), Some(tag_selector), )?; } Err(BrzError::NotBranchError(..)) => {} Err(e) => return Err(e), }; } Ok(()) } /// Push changes to a branch. /// /// # Arguments /// * `local_branch` - Local branch to push /// * `main_branch` - Main branch to push to /// * `forge` - Forge to push to /// * `possible_transports` - Possible transports to use /// * `additional_colocated_branches` - Additional colocated branches to push /// * `tags` - Tags to push /// * `stop_revision` - Revision to stop pushing at pub fn push_changes( local_branch: &dyn Branch, main_branch: &dyn Branch, forge: Option<&Forge>, possible_transports: Option<&mut Vec>, additional_colocated_branches: Option>, tags: Option>, stop_revision: Option<&RevisionId>, ) -> Result<(), Error> { let push_url = if let Some(forge) = forge { forge.get_push_url(main_branch) } else { main_branch.get_user_url() }; log::info!("pushing to {}", push_url); let target_branch = open_branch(&push_url, possible_transports, None, None)?; push_result( local_branch, target_branch.as_ref(), additional_colocated_branches, tags, stop_revision, ) .map_err(Into::into) } /// Find an existing derived branch with the specified name, and proposal. /// /// # Arguments: /// /// * `main_branch` - Main branch /// * `forge` - The forge /// * `name` - Name of the derived branch /// * `overwrite_unrelated` - Whether to overwrite existing (but unrelated) branches /// * `owner` - Owner of the branch /// * `preferred_schemes` - List of preferred schemes /// /// # Returns: /// Tuple with (resume_branch, overwrite_existing, existing_proposal) /// The resume_branch is the branch to continue from; overwrite_existing /// means there is an existing branch in place that should be overwritten. pub fn find_existing_proposed( main_branch: &dyn Branch, forge: &Forge, name: &str, overwrite_unrelated: bool, owner: Option<&str>, preferred_schemes: Option<&[&str]>, ) -> Result< ( Option>, Option, Option>, ), BrzError, > { let existing_branch = match forge.get_derived_branch(main_branch, name, owner, preferred_schemes) { Ok(branch) => branch, Err(BrzError::NotBranchError(..)) => { return Ok((None, None, None)); } Err(e) => return Err(e), }; log::info!( "Branch {} already exists (branch at {})", name, crate::vcs::full_branch_url(existing_branch.as_ref()) ); let mut open_proposals = vec![]; // If there is an open or rejected merge proposal, resume that. let mut merged_proposals = vec![]; for mp in forge.iter_proposals( existing_branch.as_ref(), main_branch, breezyshim::MergeProposalStatus::All, )? { if !mp.is_closed()? && !mp.is_merged()? { open_proposals.push(mp); } else { merged_proposals.push(mp); } } if !open_proposals.is_empty() { Ok((Some(existing_branch), Some(false), Some(open_proposals))) } else if let Some(first_proposal) = merged_proposals.first() { log::info!( "There is a proposal that has already been merged at {}.", first_proposal.url()? ); Ok((None, Some(true), None)) } else { // No related merge proposals found, but there is an existing // branch (perhaps for a different target branch?) if overwrite_unrelated { Ok((None, Some(true), None)) } else { //TODO(jelmer): What to do in this case? Ok((None, Some(false), None)) } } } /// Create or update a merge proposal. /// /// # Arguments /// /// * `local_branch` - Local branch with changes to propose /// * `main_branch` - Target branch to propose against /// * `forge` - Associated forge for main branch /// * `mp_description` - Merge proposal description /// * `resume_branch` - Existing derived branch /// * `resume_proposal` - Existing merge proposal to resume /// * `overwrite_existing` - Whether to overwrite any other existing branch /// * `labels` - Labels to add /// * `commit_message` - Optional commit message /// * `title` - Optional title /// * `additional_colocated_branches` - Additional colocated branches to propose /// * `allow_empty` - Whether to allow empty merge proposals /// * `reviewers` - List of reviewers /// * `tags` - Tags to push (None for default behaviour) /// * `owner` - Derived branch owner /// * `stop_revision` - Revision to stop pushing at /// * `allow_collaboration` - Allow target branch owners to modify source branch /// * `auto_merge` - Enable merging once CI passes /// * `preferred_schemes` - List of preferred schemes /// * `overwrite_unrelated` - Whether to overwrite existing (but unrelated) branches /// /// # Returns /// Tuple with (proposal, is_new) pub fn propose_changes( local_branch: &dyn Branch, main_branch: &dyn Branch, forge: &Forge, name: &str, mp_description: &str, resume_branch: Option<&dyn Branch>, mut resume_proposal: Option, overwrite_existing: Option, labels: Option>, commit_message: Option<&str>, title: Option<&str>, additional_colocated_branches: Option>, allow_empty: Option, reviewers: Option>, tags: Option>, owner: Option<&str>, stop_revision: Option<&RevisionId>, allow_collaboration: Option, auto_merge: Option, ) -> Result<(MergeProposal, bool), Error> { let mut ref_resume_branch = None; if !allow_empty.unwrap_or(false) && check_proposal_diff_empty(local_branch, main_branch, stop_revision)? { return Err(Error::EmptyMergeProposal); } let overwrite_existing = overwrite_existing.unwrap_or(true); let remote_branch = if let Some(resume_branch) = resume_branch { local_branch.push( resume_branch, overwrite_existing, stop_revision, tags.as_ref().map(|ts| { Box::new(_tag_selector_from_tags(ts.clone())) as Box bool> }), )?; resume_branch } else { ref_resume_branch = Some( forge .publish_derived( local_branch, main_branch, name, Some(overwrite_existing), owner, stop_revision, tags.clone().map(|ts| { Box::new(_tag_selector_from_tags(ts)) as Box bool> }), )? .0, ); ref_resume_branch.as_ref().unwrap().as_ref() }; for (from_branch_name, to_branch_name) in additional_colocated_branches.unwrap_or_default() { match local_branch .controldir() .open_branch(Some(from_branch_name.as_str())) { Ok(b) => { remote_branch.controldir().push_branch( b.as_ref(), Some(to_branch_name.as_str()), None, Some(overwrite_existing), tags.clone().map(|ts| { Box::new(_tag_selector_from_tags(ts)) as Box bool> }), )?; } Err(BrzError::NotBranchError(..)) => {} Err(e) => return Err(e.into()), } } if let Some(mp) = resume_proposal.as_ref() { if mp.is_closed()? { match mp.reopen() { Ok(_) => {} Err(e) => { log::info!( "Reopening existing proposal failed ({}). Creating new proposal.", e ); resume_proposal = None; } } } } if let Some(resume_proposal) = resume_proposal.take() { // Check that the proposal doesn't already has this description. // Setting the description (regardless of whether it changes) // causes Launchpad to send emails. if resume_proposal.get_description()?.as_deref() != Some(mp_description) { match resume_proposal.set_description(Some(mp_description)) { Ok(_) => (), Err(BrzError::UnsupportedOperation(..)) => (), Err(e) => return Err(e.into()), } } if resume_proposal.get_commit_message()?.as_deref() != commit_message { match resume_proposal.set_commit_message(commit_message) { Ok(_) => (), Err(BrzError::UnsupportedOperation(..)) => (), Err(e) => return Err(e.into()), } } if resume_proposal.get_title()?.as_deref() != title { match resume_proposal.set_title(title) { Ok(_) => (), Err(BrzError::UnsupportedOperation(..)) => (), Err(e) => return Err(e.into()), } } Ok((resume_proposal, false)) } else { let mut proposal_builder = forge.get_proposer(remote_branch, main_branch)?; std::mem::drop(ref_resume_branch); if forge.supports_merge_proposal_commit_message() { if let Some(commit_message) = commit_message { proposal_builder = proposal_builder.commit_message(commit_message); } } if forge.supports_merge_proposal_title() { if let Some(title) = title { proposal_builder = proposal_builder.title(title); } } if let Some(allow_collaboration) = allow_collaboration { proposal_builder = proposal_builder.allow_collaboration(allow_collaboration); } proposal_builder = proposal_builder.description(mp_description); if let Some(labels) = labels { proposal_builder = proposal_builder.labels( labels .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), ); } if let Some(reviewers) = reviewers { proposal_builder = proposal_builder.reviewers( reviewers .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), ); } let mp: MergeProposal = match proposal_builder.build() { Ok(mp) => mp, Err(BrzError::MergeProposalExists(_url, Some(existing_proposal_url))) => { MergeProposal::from_url(&existing_proposal_url)? } Err(e @ BrzError::PermissionDenied(..)) => { log::info!("Permission denied while trying to create proposal."); return Err(e.into()); } Err(e) => return Err(e.into()), }; if auto_merge.unwrap_or(false) { mp.merge(true)?; } Ok((mp, true)) } } #[derive(Debug)] /// Error type for publishing pub enum Error { /// Diverged branches DivergedBranches(), /// An unrelated branch existed UnrelatedBranchExists, /// Other vcs error Other(BrzError), /// Unsupported forge UnsupportedForge(url::Url), /// Forge login required ForgeLoginRequired, /// Insufficient changes for new proposal InsufficientChangesForNewProposal, /// Branch open error BranchOpenError(crate::vcs::BranchOpenError), /// Empty merge proposal EmptyMergeProposal, /// Permission denied PermissionDenied, /// No target branch NoTargetBranch, } impl From for Error { fn from(e: BrzError) -> Self { match e { BrzError::DivergedBranches => Error::DivergedBranches(), BrzError::NotBranchError(..) => Error::UnrelatedBranchExists, BrzError::PermissionDenied(..) => Error::PermissionDenied, BrzError::UnsupportedForge(s) => Error::UnsupportedForge(s), BrzError::ForgeLoginRequired => Error::ForgeLoginRequired, _ => Error::Other(e), } } } impl From for Error { fn from(e: crate::vcs::BranchOpenError) -> Self { Error::BranchOpenError(e) } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Error::DivergedBranches() => write!(f, "Diverged branches"), Error::Other(e) => write!(f, "{}", e), Error::UnsupportedForge(u) => write!(f, "Unsupported forge: {}", u), Error::ForgeLoginRequired => write!(f, "Forge login required"), Error::BranchOpenError(e) => write!(f, "{}", e), Error::EmptyMergeProposal => write!(f, "Empty merge proposal"), Error::PermissionDenied => write!(f, "Permission denied"), Error::UnrelatedBranchExists => write!(f, "Unrelated branch exists"), Error::InsufficientChangesForNewProposal => { write!(f, "Insufficient changes for new proposal") } Error::NoTargetBranch => write!(f, "No target branch"), } } } #[cfg(feature = "pyo3")] impl From for pyo3::PyErr { fn from(e: Error) -> Self { use pyo3::import_exception; use pyo3::prelude::*; import_exception!(breezy.errors, NotBranchError); import_exception!(breezy.errors, UnsupportedOperation); import_exception!(breezy.errors, MergeProposalExists); import_exception!(breezy.errors, PermissionDenied); import_exception!(breezy.errors, DivergedBranches); import_exception!(breezy.forge, UnsupportedForge); import_exception!(breezy.forge, ForgeLoginRequired); import_exception!(silver_platter, EmptyMergeProposal); import_exception!(silver_platter, UnrelatedBranchExists); import_exception!(silver_platter, InsufficientChangesForNewProposal); import_exception!(silver_platter, NoTargetBranch); match e { Error::DivergedBranches() => PyErr::new::("DivergedBranches"), Error::Other(e) => e.into(), Error::BranchOpenError(e) => e.into(), Error::UnsupportedForge(u) => PyErr::new::(u.to_string()), Error::ForgeLoginRequired => PyErr::new::("ForgeLoginRequired"), Error::UnrelatedBranchExists => { PyErr::new::("UnrelatedBranchExists") } Error::PermissionDenied => PyErr::new::("PermissionDenied"), Error::EmptyMergeProposal => PyErr::new::("EmptyMergeProposal"), Error::InsufficientChangesForNewProposal => { PyErr::new::( "InsufficientChangesForNewProposal", ) } Error::NoTargetBranch => PyErr::new::(()), } } } /// Publish a set of changes. /// /// # Arguments /// * `local_branch` - Local branch to publish /// * `main_branch` - Main branch to publish to /// * `resume_branch` - Branch to resume publishing from /// * `mode` - Mode to use ('push', 'push-derived', 'propose') /// * `name` - Branch name to push /// * `get_proposal_description` - Function to retrieve proposal description /// * `get_proposal_commit_message` - Function to retrieve proposal commit message /// * `get_proposal_title` - Function to retrieve proposal title /// * `forge` - Forge, if known /// * `allow_create_proposal` - Whether to allow creating proposals /// * `labels` - Labels to set for any merge proposals /// * `overwrite_existing` - Whether to overwrite existing (but unrelated) branch /// * `existing_proposal` - Existing proposal to update /// * `reviewers` - List of reviewers for merge proposal /// * `tags` - Tags to push (None for default behaviour) /// * `derived_owner` - Name of any derived branch /// * `allow_collaboration` - Whether to allow target branch owners to modify source branch. /// * `auto_merge` - Enable merging once CI passes pub fn publish_changes( local_branch: &dyn Branch, main_branch: &dyn Branch, resume_branch: Option<&dyn Branch>, mut mode: Mode, name: &str, get_proposal_description: impl FnOnce(DescriptionFormat, Option<&MergeProposal>) -> String, get_proposal_commit_message: Option) -> Option>, get_proposal_title: Option) -> Option>, forge: Option<&Forge>, allow_create_proposal: Option, labels: Option>, overwrite_existing: Option, existing_proposal: Option, reviewers: Option>, tags: Option>, derived_owner: Option<&str>, allow_collaboration: Option, stop_revision: Option<&RevisionId>, auto_merge: Option, ) -> Result { let stop_revision = stop_revision.map_or_else(|| local_branch.last_revision(), |r| r.clone()); let allow_create_proposal = allow_create_proposal.unwrap_or(true); let forge = match forge { Some(forge) => forge.clone(), None => breezyshim::forge::get_forge(main_branch)?, }; if stop_revision == main_branch.last_revision() { if let Some(existing_proposal) = existing_proposal.as_ref() { log::info!("closing existing merge proposal - no new revisions"); existing_proposal.close()?; } return Ok(PublishResult { mode, target_branch: main_branch.get_user_url(), forge, proposal: existing_proposal, is_new: Some(false), }); } if let Some(resume_branch) = resume_branch { if resume_branch.last_revision() == stop_revision { // No new revisions added on this iteration, but changes since main // branch. We may not have gotten round to updating/creating the // merge proposal last time. log::info!("No changes added; making sure merge proposal is up to date."); } } let write_lock = main_branch.lock_write()?; match mode { Mode::PushDerived => { let (_remote_branch, _public_url) = push_derived_changes( local_branch, main_branch, &forge, name, overwrite_existing, derived_owner, tags, Some(&stop_revision), )?; return Ok(PublishResult { mode, target_branch: main_branch.get_user_url(), forge: forge.clone(), proposal: None, is_new: None, }); } Mode::Push | Mode::AttemptPush => { let read_lock = local_branch.lock_read()?; // breezy would do this check too, but we want to be *really* sure. let graph = local_branch.repository().get_graph(); if !graph.is_ancestor(&main_branch.last_revision(), &stop_revision) { return Err(Error::DivergedBranches()); } std::mem::drop(read_lock); match push_changes( local_branch, main_branch, Some(&forge), None, None, tags.clone(), Some(&stop_revision), ) { Err(e @ Error::PermissionDenied) => { if mode == Mode::AttemptPush { log::info!("push access denied, falling back to propose"); mode = Mode::Propose; } else { log::info!("permission denied during push"); return Err(e); } } Ok(_) => { return Ok(PublishResult { proposal: None, mode, target_branch: main_branch.get_user_url(), forge: forge.clone(), is_new: None, }); } Err(e) => { return Err(e); } } } Mode::Bts => { unimplemented!(); } Mode::Propose => { // Handled below } } assert_eq!(mode, Mode::Propose); if resume_branch.is_none() && !allow_create_proposal { return Err(Error::InsufficientChangesForNewProposal); } let mp_description = get_proposal_description( forge.merge_proposal_description_format().parse().unwrap(), if resume_branch.is_some() { existing_proposal.as_ref() } else { None }, ); let commit_message = if let Some(get_proposal_commit_message) = get_proposal_commit_message { get_proposal_commit_message(if resume_branch.is_some() { existing_proposal.as_ref() } else { None }) } else { None }; let title = if let Some(get_proposal_title) = get_proposal_title { get_proposal_title(if resume_branch.is_some() { existing_proposal.as_ref() } else { None }) } else { None }; let title = if let Some(title) = title { Some(title) } else { match breezyshim::forge::determine_title(mp_description.as_str()) { Ok(title) => Some(title), Err(e) => { log::warn!("Failed to determine title from description: {}", e); None } } }; let (proposal, is_new) = propose_changes( local_branch, main_branch, &forge, name, mp_description.as_str(), resume_branch, existing_proposal, overwrite_existing, labels, commit_message.as_deref(), title.as_deref(), None, None, reviewers, tags, derived_owner, Some(&stop_revision), allow_collaboration, auto_merge, )?; std::mem::drop(write_lock); Ok(PublishResult { mode, proposal: Some(proposal), is_new: Some(is_new), target_branch: main_branch.get_user_url(), forge, }) } /// Publish result pub struct PublishResult { /// Publish mode pub mode: Mode, /// Merge proposal pub proposal: Option, /// Whether the proposal is new pub is_new: Option, /// Target branch pub target_branch: url::Url, /// Forge pub forge: Forge, } /// Check whether a proposal has any changes. pub fn check_proposal_diff_empty( other_branch: &dyn Branch, main_branch: &dyn Branch, stop_revision: Option<&RevisionId>, ) -> Result { let stop_revision = match stop_revision { Some(rev) => rev.clone(), None => other_branch.last_revision(), }; let main_revid = main_branch.last_revision(); let other_repository = other_branch.repository(); other_repository.fetch(&main_branch.repository(), Some(&main_revid))?; let lock = other_branch.lock_read(); let main_tree = other_repository.revision_tree(&main_revid)?; let revision_graph = other_repository.get_graph(); let tree_branch = MemoryBranch::new(&other_repository, None, &main_revid); let mut merger = Merger::new(&tree_branch, &main_tree, &revision_graph); merger.set_other_revision(&stop_revision, other_branch)?; if merger.find_base()?.is_none() { merger.set_base_revision(&RevisionId::null(), other_branch)?; } merger.set_merge_type(MergeType::Merge3); let tree_merger = merger.make_merger()?; let tt = tree_merger.make_preview_transform()?; let mut changes = tt.iter_changes()?; std::mem::drop(lock); Ok(!changes.any(|_| true)) } /// Enable tag pushing for a branch pub fn enable_tag_pushing(branch: &dyn Branch) -> Result<(), BrzError> { let config = branch.get_config(); config.set_user_option("branch.fetch_tags", true)?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_no_new_commits() { use breezyshim::controldir::create_standalone_workingtree; use breezyshim::controldir::ControlDirFormat; let td = tempfile::tempdir().unwrap(); let orig = td.path().join("orig"); let tree = create_standalone_workingtree(&orig, &ControlDirFormat::default()).unwrap(); std::fs::write(orig.join("a"), "a").unwrap(); tree.add(&[std::path::Path::new("a")]).unwrap(); tree.build_commit().message("blah").commit().unwrap(); let proposal_url = url::Url::from_file_path(orig.join("proposal")).unwrap(); let proposal = tree .controldir() .sprout(proposal_url, None, None, None, None) .unwrap() .open_branch(None) .unwrap(); assert!( check_proposal_diff_empty(proposal.as_ref(), tree.branch().as_ref(), None).unwrap() ); } #[test] fn test_no_op_commits() { use breezyshim::controldir::create_standalone_workingtree; use breezyshim::controldir::ControlDirFormat; let td = tempfile::tempdir().unwrap(); let orig = td.path().join("orig"); let tree = create_standalone_workingtree(&orig, &ControlDirFormat::default()).unwrap(); std::fs::write(orig.join("a"), "a").unwrap(); tree.add(&[std::path::Path::new("a")]).unwrap(); tree.build_commit().message("blah").commit().unwrap(); let proposal_url = url::Url::from_file_path(orig.join("proposal")).unwrap(); let proposal = tree .controldir() .sprout(proposal_url, None, None, None, None) .unwrap() .open_workingtree() .unwrap(); proposal .build_commit() .message("another commit that is pointless") .commit() .unwrap(); assert!(check_proposal_diff_empty( proposal.branch().as_ref(), tree.branch().as_ref(), None ) .unwrap()); } #[test] fn test_indep() { use breezyshim::bazaar::tree::MutableInventoryTree; use breezyshim::bazaar::FileId; use breezyshim::controldir::create_standalone_workingtree; use breezyshim::controldir::ControlDirFormat; let td = tempfile::tempdir().unwrap(); let orig = td.path().join("orig"); let tree = create_standalone_workingtree(&orig, &ControlDirFormat::default()).unwrap(); std::fs::write(orig.join("a"), "a").unwrap(); tree.add(&[std::path::Path::new("a")]).unwrap(); tree.build_commit().message("blah").commit().unwrap(); std::fs::write(orig.join("b"), "b").unwrap(); std::fs::write(orig.join("c"), "c").unwrap(); tree.add(&[std::path::Path::new("b"), std::path::Path::new("c")]) .unwrap(); tree.build_commit().message("independent").commit().unwrap(); let proposal_path = orig.join("proposal"); let proposal_url = url::Url::from_file_path(proposal_path.as_path()).unwrap(); let proposal = tree .controldir() .sprout(proposal_url, None, None, None, None) .unwrap() .open_workingtree() .unwrap(); assert!(proposal_path.exists()); std::fs::write(proposal_path.join("b"), "b").unwrap(); if proposal.supports_setting_file_ids() { MutableInventoryTree::add( &proposal, &[std::path::Path::new("b")], &[FileId::from("b")], ) .unwrap(); } else { proposal.add(&[std::path::Path::new("b")]).unwrap(); } proposal .build_commit() .message("not pointless") .commit() .unwrap(); assert!(check_proposal_diff_empty( proposal.branch().as_ref(), tree.branch().as_ref(), None ) .unwrap()); std::mem::drop(td); } #[test] fn test_changes() { use breezyshim::controldir::create_standalone_workingtree; use breezyshim::controldir::ControlDirFormat; let td = tempfile::tempdir().unwrap(); let orig = td.path().join("orig"); let tree = create_standalone_workingtree(&orig, &ControlDirFormat::default()).unwrap(); std::fs::write(orig.join("a"), "a").unwrap(); tree.add(&[std::path::Path::new("a")]).unwrap(); tree.build_commit().message("blah").commit().unwrap(); let proposal_url = url::Url::from_file_path(td.path().join("proposal")).unwrap(); let proposal_tree = tree .controldir() .sprout(proposal_url, None, None, None, None) .unwrap() .open_workingtree() .unwrap(); std::fs::write(proposal_tree.basedir().join("b"), "b").unwrap(); proposal_tree.add(&[std::path::Path::new("b")]).unwrap(); proposal_tree .build_commit() .message("not pointless") .commit() .unwrap(); assert!(!check_proposal_diff_empty( proposal_tree.branch().as_ref(), tree.branch().as_ref(), None ) .unwrap()); } #[test] fn test_push_result() { use breezyshim::controldir::{ create_branch_convenience, create_standalone_workingtree, ControlDirFormat, }; let td = tempfile::tempdir().unwrap(); let target_path = td.path().join("target"); let source_path = td.path().join("source"); let target_url = url::Url::from_file_path(target_path).unwrap(); let target = create_branch_convenience(&target_url, None, &ControlDirFormat::default()).unwrap(); let source = create_standalone_workingtree(&source_path, &ControlDirFormat::default()).unwrap(); let revid = source .build_commit() .message("Some change") .commit() .unwrap(); push_result(source.branch().as_ref(), target.as_ref(), None, None, None).unwrap(); assert_eq!(target.last_revision(), revid); } } silver-platter-0.5.44/src/recipe.rs0000644000000000000000000002300114721061524014111 0ustar00//! Recipes use crate::proposal::DescriptionFormat; use crate::Mode; use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] /// Merge request configuration pub struct MergeRequest { #[serde(rename = "commit-message")] #[serde(default, skip_serializing_if = "Option::is_none")] /// Commit message template pub commit_message: Option, /// Title template #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(rename = "propose-threshold")] #[serde(default, skip_serializing_if = "Option::is_none")] /// Value threshold for proposing the merge request pub propose_threshold: Option, /// Description templates #[serde(default, deserialize_with = "deserialize_description")] pub description: HashMap, String>, /// Whether to enable automatic merge #[serde( rename = "auto-merge", default, skip_serializing_if = "Option::is_none" )] pub auto_merge: Option, } fn deserialize_description<'de, D>( deserializer: D, ) -> Result, String>, D::Error> where D: serde::Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum StringOrMap { String(String), Map(HashMap, String>), } let helper = StringOrMap::deserialize(deserializer)?; let mut result = HashMap::new(); match helper { StringOrMap::String(s) => { result.insert(None, s); } StringOrMap::Map(m) => { result = m; } } Ok(result) } impl MergeRequest { /// Render a commit message pub fn render_commit_message(&self, context: &tera::Context) -> tera::Result> { let mut tera = tera::Tera::default(); self.commit_message .as_ref() .map(|m| tera.render_str(m, context)) .transpose() } /// Render the title of the merge request pub fn render_title(&self, context: &tera::Context) -> tera::Result> { let mut tera = tera::Tera::default(); self.title .as_ref() .map(|m| tera.render_str(m, context)) .transpose() } /// Render the description of the merge request pub fn render_description( &self, description_format: DescriptionFormat, context: &tera::Context, ) -> tera::Result> { let mut tera = tera::Tera::default(); let template = if let Some(template) = self.description.get(&Some(description_format)) { template } else if let Some(template) = self.description.get(&None) { template } else { return Ok(None); }; Ok(Some(tera.render_str(template.as_str(), context)?)) } } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] #[serde(untagged)] /// Command as either a shell string or a vector of arguments pub enum Command { /// Command as a shell string Shell(String), /// Command as a vector of arguments Argv(Vec), } impl Command { /// Get the command as a shell string pub fn shell(&self) -> String { match self { Command::Shell(s) => s.clone(), Command::Argv(v) => { let args = v.iter().map(|x| x.as_str()).collect::>(); shlex::try_join(args).unwrap() } } } /// Get the command as a vector of arguments pub fn argv(&self) -> Vec { match self { Command::Shell(s) => vec!["sh".to_string(), "-c".to_string(), s.clone()], Command::Argv(v) => v.clone(), } } } /// A recipe builder pub struct RecipeBuilder { recipe: Recipe, } impl RecipeBuilder { /// Create a new recipe builder pub fn new() -> Self { Self { recipe: Recipe { name: None, merge_request: None, labels: None, command: None, mode: None, resume: None, commit_pending: crate::CommitPending::default(), }, } } /// Set the name of the recipe pub fn name(mut self, name: String) -> Self { self.recipe.name = Some(name); self } /// Set the merge request configuration pub fn merge_request(mut self, merge_request: MergeRequest) -> Self { self.recipe.merge_request = Some(merge_request); self } /// Set the labels to apply to the merge request pub fn labels(mut self, labels: Vec) -> Self { self.recipe.labels = Some(labels); self } /// Set a label to apply to the merge request pub fn label(mut self, label: String) -> Self { if let Some(labels) = &mut self.recipe.labels { labels.push(label); } else { self.recipe.labels = Some(vec![label]); } self } /// Set the command to run pub fn command(mut self, command: Command) -> Self { self.recipe.command = Some(command); self } /// Set the command to run as an argv pub fn argv(mut self, argv: Vec) -> Self { self.recipe.command = Some(Command::Argv(argv)); self } /// Set the command to run as a shell string pub fn shell(mut self, shell: String) -> Self { self.recipe.command = Some(Command::Shell(shell)); self } /// Set the mode to run the recipe in pub fn mode(mut self, mode: Mode) -> Self { self.recipe.mode = Some(mode); self } /// Set whether to resume a previous run pub fn resume(mut self, resume: bool) -> Self { self.recipe.resume = Some(resume); self } /// Set whether to commit pending changes pub fn commit_pending(mut self, commit_pending: crate::CommitPending) -> Self { self.recipe.commit_pending = commit_pending; self } /// Build the recipe pub fn build(self) -> Recipe { self.recipe } } #[derive(Debug, Serialize, Deserialize, Clone)] /// A recipe pub struct Recipe { /// Name of the recipe pub name: Option, #[serde(rename = "merge-request")] /// Merge request configuration pub merge_request: Option, /// Labels to apply to the merge request #[serde(default, skip_serializing_if = "Option::is_none")] pub labels: Option>, /// Command to run pub command: Option, /// Mode to run the recipe in pub mode: Option, /// Whether to resume a previous run #[serde(default, skip_serializing_if = "Option::is_none")] pub resume: Option, #[serde(rename = "commit-pending")] /// Whether to commit pending changes #[serde(default, skip_serializing_if = "crate::CommitPending::is_default")] pub commit_pending: crate::CommitPending, } impl Recipe { /// Load a recipe from a file pub fn from_path(path: &std::path::Path) -> std::io::Result { let file = std::fs::File::open(path)?; let mut recipe: Recipe = serde_yaml::from_reader(file).unwrap(); if recipe.name.is_none() { recipe.name = Some(path.file_stem().unwrap().to_str().unwrap().to_string()); } Ok(recipe) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_simple() { let td = tempfile::tempdir().unwrap(); let path = td.path().join("test.yaml"); std::fs::write( &path, r#"--- name: test command: ["echo", "hello"] mode: propose merge-request: commit-message: "test commit message" title: "test title" description: plain: "test description" "#, ) .unwrap(); let recipe = Recipe::from_path(&path).unwrap(); assert_eq!(recipe.name, Some("test".to_string())); assert_eq!( recipe.command.unwrap().argv(), vec!["echo".to_string(), "hello".to_string()] ); assert_eq!(recipe.mode, Some(Mode::Propose)); assert_eq!( recipe.merge_request, Some(MergeRequest { commit_message: Some("test commit message".to_string()), title: Some("test title".to_string()), propose_threshold: None, auto_merge: None, description: vec![( Some(DescriptionFormat::Plain), "test description".to_string() )] .into_iter() .collect(), }) ); } #[test] fn test_builder() { let recipe = RecipeBuilder::new() .name("test".to_string()) .command(Command::Argv(vec!["echo".to_string(), "hello".to_string()])) .mode(Mode::Propose) .merge_request(MergeRequest { commit_message: Some("test commit message".to_string()), title: Some("test title".to_string()), propose_threshold: None, auto_merge: None, description: vec![( Some(DescriptionFormat::Plain), "test description".to_string(), )] .into_iter() .collect(), }) .build(); assert_eq!(recipe.name, Some("test".to_string())); assert_eq!( recipe.command.unwrap().argv(), vec!["echo".to_string(), "hello".to_string()] ); } } silver-platter-0.5.44/src/run.rs0000644000000000000000000002216314721061524013456 0ustar00//! Run the codemod script and publish the changes as a merge proposal. use crate::codemod::{CommandResult, Error as CommandError}; use crate::publish::{ enable_tag_pushing, find_existing_proposed, DescriptionFormat, Error as PublishError, }; use crate::vcs::{open_branch, BranchOpenError}; use crate::workspace::Workspace; use crate::Mode; use breezyshim::branch::Branch; use breezyshim::error::Error as BrzError; use breezyshim::forge::{get_forge, Forge, MergeProposal}; use log::{error, info, warn}; use std::collections::HashMap; use url::Url; /// Apply a codemod script and publish the changes as a merge proposal. pub fn apply_and_publish( url: &Url, name: &str, command: &[&str], mode: Mode, commit_pending: crate::CommitPending, labels: Option<&[&str]>, diff: bool, verify_command: Option<&str>, derived_owner: Option<&str>, refresh: bool, allow_create_proposal: Option bool>, mut get_commit_message: Option< impl FnOnce(&CommandResult, Option<&MergeProposal>) -> Option, >, get_title: Option) -> Option>, get_description: impl FnOnce(&CommandResult, DescriptionFormat, Option<&MergeProposal>) -> String, extra_env: Option>, ) -> i32 { let main_branch = match open_branch(url, None, None, None) { Err(BranchOpenError::Unavailable { url, description, .. }) | Err(BranchOpenError::Missing { url, description, .. }) | Err(BranchOpenError::RateLimited { url, description, .. }) | Err(BranchOpenError::TemporarilyUnavailable { url, description, .. }) | Err(BranchOpenError::Unsupported { url, description, .. }) => { error!("{}: {}", url, description); return 2; } Err(BranchOpenError::Other(e)) => { error!("{}: {}", url, e); return 2; } Ok(b) => b, }; let mut overwrite = false; let (forge, existing_proposals, mut resume_branch): ( Option>, Vec, Option>, ) = match get_forge(main_branch.as_ref()) { Err(BrzError::UnsupportedForge(e)) => { if mode != Mode::Push { error!("{}: {}", url, e); return 2; } // We can't figure out what branch to resume from when there's no forge // that can tell us. warn!( "Unsupported forge ({}), will attempt to push to {}", e, crate::vcs::full_branch_url(main_branch.as_ref()), ); (None, vec![], None) } Err(BrzError::ForgeProjectExists(_)) | Err(BrzError::AlreadyControlDir(..)) => { unreachable!() } Err(BrzError::ForgeLoginRequired) => { warn!("Login required to access forge"); return 2; } Err(e) => { error!("Failed to get forge: {}", e); return 2; } Ok(forge) => { let (resume_branch, resume_overwrite, existing_proposals) = match find_existing_proposed( main_branch.as_ref(), &forge, name, false, derived_owner, None, ) { Ok(r) => r, Err(e) => { error!("Failed to find existing proposals: {}", e); return 2; } }; if let Some(resume_overwrite) = resume_overwrite { overwrite = resume_overwrite; } ( Some(Box::new(forge)), existing_proposals.unwrap_or_default(), resume_branch, ) } }; if refresh { if resume_branch.is_some() { overwrite = true; } resume_branch = None; } let existing_proposal = if existing_proposals.len() > 1 { warn!( "Multiple open merge proposals for branch at {}: {:?}", resume_branch.as_ref().unwrap().get_user_url(), existing_proposals .iter() .map(|mp| mp.url().unwrap()) .collect::>() ); let existing_proposal = existing_proposals.into_iter().next().unwrap(); info!("Updating {}", existing_proposal.url().unwrap()); Some(existing_proposal) } else { None }; let subpath = std::path::Path::new(""); let mut builder = Workspace::builder().main_branch(main_branch); builder = if let Some(resume_branch) = resume_branch.take() { builder.resume_branch(resume_branch) } else { builder }; let ws = match builder.build() { Ok(ws) => ws, Err(e) => { error!("Failed to start workspace: {}", e); return 2; } }; let result: CommandResult = match crate::codemod::script_runner( ws.local_tree(), command, subpath, commit_pending, None, None, extra_env, std::process::Stdio::inherit(), ) { Ok(r) => r, Err(CommandError::ScriptMadeNoChanges) => { error!("Script did not make any changes."); return 0; } Err(e) => { error!("Script failed: {}", e); return 2; } }; if let Some(verify_command) = verify_command { match std::process::Command::new("sh") .arg("-c") .arg(verify_command) .current_dir(ws.local_tree().abspath(std::path::Path::new(".")).unwrap()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .output() { Ok(output) => { if output.status.success() { info!("Verify command succeeded."); } else { error!("Verify command failed."); return 2; } } Err(e) => { error!("Verify command failed: {}", e); return 2; } } } enable_tag_pushing(ws.local_tree().branch().as_ref()).unwrap(); let result_ref = result.clone(); let get_commit_message = get_commit_message .take() .map(|f| move |ep: Option<&MergeProposal>| -> Option { f(&result_ref, ep) }); let result_ref = result.clone(); let publish_result = match ws.publish_changes( None, mode, name, |df, ep| get_description(&result, df, ep), get_commit_message, Some(move |ep: Option<&MergeProposal>| { if let Some(get_title) = get_title { get_title(&result_ref, ep) } else { None } }), forge.as_deref(), allow_create_proposal.map(|f| f(&result)), labels.map(|l| l.iter().map(|s| s.to_string()).collect()), Some(overwrite), existing_proposal, None, None, derived_owner, None, None, None, ) { Ok(r) => r, Err(PublishError::UnsupportedForge(_)) => { error!( "No known supported forge for {}. Run 'svp login'?", crate::vcs::full_branch_url(ws.main_branch().unwrap()), ); return 2; } Err(PublishError::InsufficientChangesForNewProposal) => { info!("Insufficient changes for a new merge proposal"); return 1; } Err(PublishError::ForgeLoginRequired) => { error!("Credentials for hosting site missing. Run 'svp login'?",); return 2; } Err(PublishError::DivergedBranches()) | Err(PublishError::UnrelatedBranchExists) => { error!("A branch exists on the server that has diverged from the local branch."); return 2; } Err(PublishError::BranchOpenError(e)) => { error!("Failed to open branch: {}", e); return 2; } Err(PublishError::EmptyMergeProposal) => { error!("No changes to publish."); return 2; } Err(PublishError::Other(e)) => { error!("Failed to publish changes: {}", e); return 2; } Err(PublishError::PermissionDenied) => { error!("Permission denied to create merge proposal."); return 2; } Err(PublishError::NoTargetBranch) => { unreachable!(); } }; if let Some(mp) = publish_result.proposal { if publish_result.is_new.unwrap() { info!("Merge proposal created."); } else { info!("Merge proposal updated."); } if let Ok(url) = mp.url() { info!("URL: {}", url); } info!("Description: {}", mp.get_description().unwrap().unwrap()); } if diff { ws.show_diff(Box::new(std::io::stdout()), None, None) .unwrap(); } 1 } silver-platter-0.5.44/src/utils.rs0000644000000000000000000002160714721061524014014 0ustar00//! Utility functions for working with branches. use breezyshim::branch::Branch; use breezyshim::controldir::ControlDir; use breezyshim::error::Error as BrzError; use breezyshim::merge::{Error as MergeError, MergeType, Merger, MERGE_HOOKS}; use breezyshim::tree::WorkingTree; use breezyshim::RevisionId; use std::collections::HashMap; /// A temporary sprout of a branch. pub struct TempSprout { /// The working tree of the sprout. pub workingtree: WorkingTree, /// The temporary directory that the sprout is in. pub tempdir: Option, } impl TempSprout { /// Create a temporary sprout of a branch. pub fn new( branch: &dyn Branch, additional_colocated_branches: Option>, ) -> Result { let (wt, td) = create_temp_sprout(branch, additional_colocated_branches, None, None)?; Ok(Self { workingtree: wt, tempdir: td, }) } /// Create a temporary sprout of a branch in a specific directory. pub fn new_in( branch: &dyn Branch, additional_colocated_branches: Option>, dir: &std::path::Path, ) -> Result { let (wt, tempdir) = create_temp_sprout(branch, additional_colocated_branches, Some(dir), None)?; Ok(Self { workingtree: wt, tempdir, }) } /// Create a temporary sprout of a branch with a specific path. pub fn new_in_path( branch: &dyn Branch, additional_colocated_branches: Option>, path: &std::path::Path, ) -> Result { let (wt, tempdir) = create_temp_sprout(branch, additional_colocated_branches, None, Some(path))?; Ok(Self { workingtree: wt, tempdir, }) } /// Return the tree of the sprout. pub fn tree(&self) -> &WorkingTree { &self.workingtree } } impl std::ops::Deref for TempSprout { type Target = WorkingTree; fn deref(&self) -> &Self::Target { &self.workingtree } } /// Create a temporary sprout of a branch. /// /// This attempts to fetch the least amount of history as possible. pub fn create_temp_sprout( branch: &dyn Branch, additional_colocated_branches: Option>, dir: Option<&std::path::Path>, path: Option<&std::path::Path>, ) -> Result<(WorkingTree, Option), BrzError> { let (to_dir, td) = create_temp_sprout_cd(branch, additional_colocated_branches, dir, path)?; let wt = to_dir.open_workingtree()?; Ok((wt, td)) } /// Create a temporary sprout of a branch. /// /// This attempts to fetch the least amount of history as possible. fn create_temp_sprout_cd( branch: &dyn Branch, additional_colocated_branches: Option>, dir: Option<&std::path::Path>, path: Option<&std::path::Path>, ) -> Result<(ControlDir, Option), BrzError> { let (td, path) = if let Some(path) = path { // ensure that path is absolute assert!(path.is_absolute()); (None, path.to_path_buf()) } else { let td = if let Some(dir) = dir { tempfile::tempdir_in(dir).unwrap() } else { tempfile::tempdir().unwrap() }; let path = td.path().to_path_buf(); (Some(td), path) }; // Only use stacking if the remote repository supports chks because of // https://bugs.launchpad.net/bzr/+bug/375013 let use_stacking = branch.format().supports_stacking() && branch.repository().format().supports_chks(); let to_url: url::Url = url::Url::from_directory_path(path).unwrap(); // preserve whatever source format we have. let to_dir = branch .controldir() .sprout(to_url, Some(branch), Some(true), Some(use_stacking), None)?; // TODO(jelmer): Fetch these during the initial clone for (from_branch_name, to_branch_name) in additional_colocated_branches.unwrap_or_default() { let controldir = branch.controldir(); match controldir.open_branch(Some(from_branch_name.as_str())) { Ok(add_branch) => { let local_add_branch = to_dir.create_branch(Some(to_branch_name.as_str()))?; add_branch.push(local_add_branch.as_ref(), false, None, None)?; assert_eq!(add_branch.last_revision(), local_add_branch.last_revision()); } Err(BrzError::NotBranchError(..)) | Err(BrzError::NoColocatedBranchSupport) => { // Ignore branches that don't exist or don't support colocated branches. } Err(BrzError::DependencyNotPresent(e, d)) => { panic!("Need dependency to sprout branch: {} {}", e, d); } Err(err) => { return Err(err); } } } Ok((to_dir, td)) } /// Check if there are any merge conflicts between two branches. pub fn merge_conflicts( main_branch: &dyn Branch, other_branch: &dyn Branch, other_revision: Option<&RevisionId>, ) -> Result { let other_revision = other_revision.map_or_else(|| other_branch.last_revision(), |r| r.clone()); let other_repository = other_branch.repository(); let graph = other_repository.get_graph(); if graph.is_ancestor(&main_branch.last_revision(), &other_revision) { return Ok(false); } other_repository.fetch( &main_branch.repository(), Some(&main_branch.last_revision()), )?; // Reset custom merge hooks, since they could make it harder to detect // conflicted merges that would appear on the hosting site. let old_file_contents_mergers = MERGE_HOOKS.get("merge_file_content").unwrap(); MERGE_HOOKS.clear("merge_file_contents").unwrap(); let other_tree = other_repository.revision_tree(&other_revision).unwrap(); let result = match Merger::from_revision_ids( &other_tree, other_branch, &main_branch.last_revision(), other_branch, ) { Ok(mut merger) => { merger.set_merge_type(MergeType::Merge3); let tree_merger = merger.make_merger().unwrap(); let tt = tree_merger.make_preview_transform().unwrap(); !tt.cooked_conflicts().unwrap().is_empty() } Err(MergeError::UnrelatedBranches) => { // Unrelated branches don't technically *have* to lead to // conflicts, but there's not a lot to be salvaged here, either. true } }; for hook in old_file_contents_mergers { MERGE_HOOKS.add("merge_file_content", hook).unwrap(); } Ok(result) } #[cfg(test)] mod tests { use super::*; #[test] fn test_sprout() { let base = tempfile::tempdir().unwrap(); let wt = breezyshim::controldir::create_standalone_workingtree( base.path(), &breezyshim::controldir::ControlDirFormat::default(), ) .unwrap(); let revid = wt .build_commit() .message("Initial commit") .allow_pointless(true) .commit() .unwrap(); let sprout = TempSprout::new(wt.branch().as_ref(), None).unwrap(); assert_eq!(sprout.last_revision().unwrap(), revid); let tree = sprout.tree(); assert_eq!(tree.last_revision().unwrap(), revid); std::mem::drop(sprout); } #[test] fn test_sprout_in() { let base = tempfile::tempdir().unwrap(); let wt = breezyshim::controldir::create_standalone_workingtree( base.path(), &breezyshim::controldir::ControlDirFormat::default(), ) .unwrap(); let revid = wt .build_commit() .message("Initial commit") .allow_pointless(true) .commit() .unwrap(); let sprout = TempSprout::new_in(wt.branch().as_ref(), None, base.path()).unwrap(); assert_eq!(sprout.last_revision().unwrap(), revid); let tree = sprout.tree(); assert_eq!(tree.last_revision().unwrap(), revid); std::mem::drop(sprout); } #[test] fn test_sprout_in_path() { let base = tempfile::tempdir().unwrap(); let target = tempfile::tempdir().unwrap(); let wt = breezyshim::controldir::create_standalone_workingtree( base.path(), &breezyshim::controldir::ControlDirFormat::default(), ) .unwrap(); let revid = wt .build_commit() .message("Initial commit") .allow_pointless(true) .commit() .unwrap(); let sprout = TempSprout::new_in_path(wt.branch().as_ref(), None, target.path()).unwrap(); assert_eq!(sprout.last_revision().unwrap(), revid); let tree = sprout.tree(); assert_eq!(tree.last_revision().unwrap(), revid); std::mem::drop(sprout); } } silver-platter-0.5.44/src/vcs.rs0000644000000000000000000003035114721061524013443 0ustar00//! Version control system (VCS) support. use breezyshim::controldir::{open_containing_from_transport, open_from_transport}; use breezyshim::error::Error as BrzError; use breezyshim::{ get_transport, join_segment_parameters, split_segment_parameters, Branch, Prober, Transport, }; use percent_encoding::{utf8_percent_encode, CONTROLS}; #[derive(Debug)] /// Errors that can occur when opening a branch. pub enum BranchOpenError { /// The VCS is not supported. Unsupported { /// The URL of the branch. url: url::Url, /// A description of the error. description: String, /// The VCS that is not supported. vcs: Option, }, /// The branch is missing. Missing { /// The URL of the branch. url: url::Url, /// A description of the error. description: String, }, /// The branch is rate limited. RateLimited { /// The URL of the branch. url: url::Url, /// A description of the error. description: String, /// The time to wait before retrying. retry_after: Option, }, /// The branch is unavailable. Unavailable { /// The URL of the branch. url: url::Url, /// A description of the error. description: String, }, /// The branch is temporarily unavailable. TemporarilyUnavailable { /// The URL of the branch. url: url::Url, /// A description of the error. description: String, }, /// An error occurred. Other(String), } impl std::fmt::Display for BranchOpenError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { BranchOpenError::Unsupported { url, description, vcs, } => write!( f, "Unsupported VCS for {}: {} ({})", url, description, vcs.as_deref().unwrap_or("unknown") ), BranchOpenError::Missing { url, description } => { write!(f, "Missing branch {}: {}", url, description) } BranchOpenError::RateLimited { url, description, retry_after, } => write!( f, "Rate limited {}: {} (retry after: {:?})", url, description, retry_after ), BranchOpenError::Unavailable { url, description } => { write!(f, "Unavailable {}: {}", url, description) } BranchOpenError::TemporarilyUnavailable { url, description } => { write!(f, "Temporarily unavailable {}: {}", url, description) } BranchOpenError::Other(e) => write!(f, "Error: {}", e), } } } #[cfg(feature = "pyo3")] impl From for pyo3::PyErr { fn from(e: BranchOpenError) -> Self { use pyo3::import_exception; import_exception!(silver_platter, BranchUnsupported); import_exception!(silver_platter, BranchTemporarilyUnavailable); import_exception!(silver_platter, BranchUnavailable); import_exception!(silver_platter, BranchRateLimited); import_exception!(silver_platter, BranchMissing); use pyo3::exceptions::PyRuntimeError; match e { BranchOpenError::Unsupported { url, description, vcs, } => BranchUnsupported::new_err((url.to_string(), description, vcs)), BranchOpenError::Missing { url, description } => { BranchMissing::new_err((url.to_string(), description)) } BranchOpenError::RateLimited { url, description, retry_after, } => BranchRateLimited::new_err((url.to_string(), description, retry_after)), BranchOpenError::Unavailable { url, description } => { BranchUnavailable::new_err((url.to_string(), description)) } BranchOpenError::TemporarilyUnavailable { url, description } => { BranchTemporarilyUnavailable::new_err((url.to_string(), description)) } BranchOpenError::Other(e) => PyRuntimeError::new_err((e,)), } } } impl BranchOpenError { /// Convert a BrzError to a BranchOpenError. pub fn from_err(url: url::Url, e: &BrzError) -> Self { match e { BrzError::NotBranchError(e, reason) => { let description = if let Some(reason) = reason { format!("{}: {}", e, reason) } else { e.to_string() }; Self::Missing { url, description } } BrzError::DependencyNotPresent(l, e) => Self::Unavailable { url, description: format!("missing {}: {}", l, e), }, BrzError::NoColocatedBranchSupport => Self::Unsupported { url, description: "no colocated branch support".to_string(), vcs: None, }, BrzError::Socket(e) => Self::Unavailable { url, description: format!("Socket error: {}", e), }, BrzError::UnsupportedProtocol(url, extra) => Self::Unsupported { url: url.parse().unwrap(), description: if let Some(extra) = extra { format!("Unsupported protocol: {}", extra) } else { "Unsupported protocol".to_string() }, vcs: None, }, BrzError::ConnectionError(msg) => { if e.to_string() .contains("Temporary failure in name resolution") { Self::TemporarilyUnavailable { url, description: msg.to_string(), } } else { Self::Unavailable { url, description: msg.to_string(), } } } BrzError::PermissionDenied(path, extra) => Self::Unavailable { url, description: format!( "Permission denied: {}: {}", path.to_string_lossy(), extra.as_deref().unwrap_or("") ), }, BrzError::InvalidURL(url, extra) => Self::Unavailable { url: url.parse().unwrap(), description: extra .as_ref() .map(|s| s.to_string()) .unwrap_or_else(|| format!("Invalid URL: {}", url)), }, BrzError::InvalidHttpResponse(_path, msg, _orig_error, headers) => { if msg.to_string().contains("Unexpected HTTP status 429") { if let Some(retry_after) = headers.get("Retry-After") { match retry_after.parse::() { Ok(retry_after) => { return Self::RateLimited { url, description: e.to_string(), retry_after: Some(retry_after), }; } Err(e) => { log::warn!("Unable to parse retry-after header: {}", retry_after); return Self::RateLimited { url, description: e.to_string(), retry_after: None, }; } } } Self::RateLimited { url, description: e.to_string(), retry_after: None, } } else { Self::Unavailable { url, description: e.to_string(), } } } BrzError::TransportError(message) => Self::Unavailable { url, description: message.to_string(), }, BrzError::UnusableRedirect(source, target, reason) => Self::Unavailable { url, description: format!("Unusable redirect: {} -> {}: {}", source, target, reason), }, BrzError::UnsupportedVcs(vcs) => Self::Unsupported { url, description: e.to_string(), vcs: Some(vcs.clone()), }, BrzError::UnsupportedFormat(format) => Self::Unsupported { url, description: e.to_string(), vcs: Some(format.clone()), }, BrzError::UnknownFormat(_format) => Self::Unsupported { url, description: e.to_string(), vcs: None, }, BrzError::RemoteGitError(msg) => Self::Unavailable { url, description: msg.to_string(), }, BrzError::LineEndingError(msg) => Self::Unavailable { url, description: msg.to_string(), }, BrzError::IncompleteRead(_partial, _expected) => Self::Unavailable { url, description: e.to_string(), }, _ => Self::Other(e.to_string()), } } } /// Open a branch from a URL. pub fn open_branch( url: &url::Url, possible_transports: Option<&mut Vec>, probers: Option<&[&dyn Prober]>, name: Option<&str>, ) -> Result, BranchOpenError> { let (url, params) = split_segment_parameters(url); let name = if let Some(name) = name { Some(name.to_string()) } else { params.get("name").map(|s| s.to_string()) }; let transport = get_transport(&url, possible_transports) .map_err(|e| BranchOpenError::from_err(url.clone(), &e))?; let dir = open_from_transport(&transport, probers) .map_err(|e| BranchOpenError::from_err(url.clone(), &e))?; dir.open_branch(name.as_deref()) .map_err(|e| BranchOpenError::from_err(url.clone(), &e)) } /// Open a branch, either at the specified URL or in a containing directory. /// /// Return the branch and the subpath of the URL that was used to open it. pub fn open_branch_containing( url: &url::Url, possible_transports: Option<&mut Vec>, probers: Option<&[&dyn Prober]>, name: Option<&str>, ) -> Result<(Box, String), BranchOpenError> { let (url, params) = split_segment_parameters(url); let name = if let Some(name) = name { Some(name.to_string()) } else { params.get("name").map(|s| s.to_string()) }; let transport = match get_transport(&url, possible_transports) { Ok(transport) => transport, Err(e) => return Err(BranchOpenError::from_err(url.clone(), &e)), }; let (dir, subpath) = open_containing_from_transport(&transport, probers).map_err(|e| match e { BrzError::UnknownFormat(_) => { unreachable!("open_containing_from_transport should not return UnknownFormat") } e => BranchOpenError::from_err(url.clone(), &e), })?; Ok(( dir.open_branch(name.as_deref()) .map_err(|e| BranchOpenError::from_err(url.clone(), &e))?, subpath, )) } /// Get the full URL for a branch. /// /// Ideally this should just return Branch.user_url, /// but that currently exclude the branch name /// in some situations. pub fn full_branch_url(branch: &dyn Branch) -> url::Url { if branch.name().is_none() { return branch.get_user_url(); } let (url, mut params) = split_segment_parameters(&branch.get_user_url()); if branch.name().as_deref() != Some("") { params.insert( "branch".to_string(), utf8_percent_encode(branch.name().unwrap().as_str(), CONTROLS).to_string(), ); } join_segment_parameters(&url, params) } silver-platter-0.5.44/src/workspace.rs0000644000000000000000000013026014721061524014646 0ustar00//! Workspace for preparing changes for publication use crate::publish::{DescriptionFormat, Error as PublishError, PublishResult}; use breezyshim::branch::Branch; use breezyshim::controldir::ControlDirFormat; use breezyshim::error::Error as BrzError; use breezyshim::forge::{Forge, MergeProposal}; use breezyshim::tree::WorkingTree; use breezyshim::ControlDir; use breezyshim::RevisionId; use std::collections::HashMap; use std::path::PathBuf; fn fetch_colocated( controldir: &ControlDir, from_controldir: &ControlDir, additional_colocated_branches: &HashMap<&str, &str>, ) -> Result<(), BrzError> { log::debug!( "Fetching colocated branches: {:?}", additional_colocated_branches ); for (from_branch_name, to_branch_name) in additional_colocated_branches.iter() { match from_controldir.open_branch(Some(from_branch_name)) { Ok(remote_colo_branch) => { controldir.push_branch( remote_colo_branch.as_ref(), Some(to_branch_name), None, Some(true), None, )?; } Err(BrzError::NotBranchError(..)) | Err(BrzError::NoColocatedBranchSupport) => { continue; } Err(e) => { return Err(e); } } } Ok(()) } #[derive(Debug)] /// An error that can occur when working with a workspace pub enum Error { /// An error from the Breezy shim BrzError(BrzError), /// An I/O error IOError(std::io::Error), /// Unknown format was specified UnknownFormat(String), /// Permission denied PermissionDenied(Option), /// Other error Other(String), } impl From for Error { fn from(e: BrzError) -> Self { match e { BrzError::UnknownFormat(n) => Error::UnknownFormat(n), BrzError::AlreadyControlDir(_) => unreachable!(), BrzError::PermissionDenied(_, m) => Error::PermissionDenied(m), e => Error::BrzError(e), } } } impl From for Error { fn from(e: std::io::Error) -> Self { Error::IOError(e) } } impl From for Error { fn from(e: PublishError) -> Self { match e { PublishError::Other(e) => Error::BrzError(e), e => Error::Other(format!("{:?}", e)), } } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Error::IOError(e) => write!(f, "{}", e), Error::UnknownFormat(n) => write!(f, "Unknown format: {}", n), Error::BrzError(e) => write!(f, "{}", e), Error::PermissionDenied(m) => write!(f, "Permission denied: {:?}", m), Error::Other(e) => write!(f, "{}", e), } } } #[derive(Default)] /// A builder for a workspace pub struct WorkspaceBuilder { main_branch: Option>, resume_branch: Option>, cached_branch: Option>, additional_colocated_branches: HashMap, resume_branch_additional_colocated_branches: HashMap, dir: Option, path: Option, format: Option, } impl WorkspaceBuilder { /// Set the main branch pub fn main_branch(mut self, main_branch: Box) -> Self { self.main_branch = Some(main_branch); self } /// Set the resume branch pub fn resume_branch(mut self, resume_branch: Box) -> Self { self.resume_branch = Some(resume_branch); self } /// Set the cached branch pub fn cached_branch(mut self, cached_branch: Box) -> Self { self.cached_branch = Some(cached_branch); self } /// Set the additional colocated branches pub fn additional_colocated_branches( mut self, additional_colocated_branches: HashMap, ) -> Self { self.additional_colocated_branches = additional_colocated_branches; self } /// Set the additional colocated branches for the resume branch pub fn resume_branch_additional_colocated_branches( mut self, resume_branch_additional_colocated_branches: HashMap, ) -> Self { self.resume_branch_additional_colocated_branches = resume_branch_additional_colocated_branches; self } /// Set the containing directory to use for the workspace pub fn dir(mut self, dir: PathBuf) -> Self { self.dir = Some(dir); self } /// Set the path to the workspace pub fn path(mut self, path: PathBuf) -> Self { self.path = Some(path); self } /// Set the control dir format to use. /// /// This defaults to the format of the remote branch. pub fn format(mut self, format: impl breezyshim::controldir::AsFormat) -> Self { self.format = format.as_format(); self } /// Build the workspace pub fn build(self) -> Result { let mut ws = Workspace { main_branch: self.main_branch, resume_branch: self.resume_branch, cached_branch: self.cached_branch, additional_colocated_branches: self.additional_colocated_branches, resume_branch_additional_colocated_branches: self .resume_branch_additional_colocated_branches, path: self.path, dir: self.dir, format: self.format, state: None, }; ws.start()?; Ok(ws) } } struct WorkspaceState { base_revid: RevisionId, local_tree: WorkingTree, refreshed: bool, tempdir: Option, main_colo_revid: HashMap, } /// A place in which changes can be prepared for publication pub struct Workspace { main_branch: Option>, cached_branch: Option>, resume_branch: Option>, additional_colocated_branches: HashMap, resume_branch_additional_colocated_branches: HashMap, dir: Option, path: Option, state: Option, format: Option, } impl Workspace { /// Create a new temporary workspace pub fn temporary() -> Result { let td = tempfile::tempdir().unwrap(); Self::builder().dir(td.into_path()).build() } /// Create a new workspace from a main branch URL pub fn from_url(url: &url::Url) -> Result { let branch = breezyshim::branch::open(url)?; Self::builder().main_branch(branch).build() } /// Start this workspace fn start(&mut self) -> Result<(), Error> { if self.state.is_some() { panic!("Workspace already started"); } let mut td: Option = None; // First, clone the main branch from the most efficient source let (sprout_base, sprout_coloc) = if let Some(cache_branch) = self.cached_branch.as_ref() { ( Some(cache_branch), self.additional_colocated_branches.clone(), ) } else if let Some(resume_branch) = self.resume_branch.as_ref() { ( Some(resume_branch), self.resume_branch_additional_colocated_branches.clone(), ) } else { ( self.main_branch.as_ref(), self.additional_colocated_branches.clone(), ) }; let (local_tree, td) = if let Some(sprout_base) = sprout_base { log::debug!("Creating sprout from {}", sprout_base.get_user_url()); let (wt, td) = crate::utils::create_temp_sprout( sprout_base.as_ref(), Some( sprout_coloc .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), ), self.dir.as_deref(), self.path.as_deref(), )?; (wt, td) } else { if let Some(format) = self.format.as_ref() { log::debug!( "Creating new empty tree with format {}", format.get_format_description() ); } else { log::debug!("Creating new empty tree"); }; let tp = if let Some(path) = self.path.as_deref() { std::fs::create_dir_all(path)?; path.to_path_buf() } else { td = Some(if let Some(dir) = self.dir.as_ref() { tempfile::tempdir_in(dir)? } else { tempfile::tempdir()? }); td.as_ref().unwrap().path().to_path_buf() }; ( breezyshim::controldir::create_standalone_workingtree( tp.as_path(), self.format .as_ref() .unwrap_or(&breezyshim::controldir::ControlDirFormat::default()), )?, td, ) }; if let Some(path) = self.path.as_ref() { breezyshim::clean_tree::clean_tree(path, true, true, true, false, true)?; } let mut main_colo_revid = std::collections::HashMap::new(); let mut refreshed = false; // If there is a main branch, ensure that revisions match if let Some(main_branch) = self.main_branch.as_ref() { for (from_name, _to_name) in self.additional_colocated_branches.iter() { match main_branch.controldir().open_branch(Some(from_name)) { Ok(branch) => { main_colo_revid.insert(from_name.to_string(), branch.last_revision()); } Err(BrzError::NotBranchError(..)) => {} Err(BrzError::NoColocatedBranchSupport) => {} Err(e) => { log::warn!("Failed to open colocated branch {}: {}", from_name, e); } } } if let Some(cached_branch) = self.cached_branch.as_ref() { log::debug!( "Pulling in missing revisions from resume/main branch {:?}", cached_branch.get_user_url() ); let from_branch = if let Some(resume_branch) = self.resume_branch.as_ref() { resume_branch.as_ref() } else { main_branch.as_ref() }; match local_tree.pull(from_branch, Some(true), None, None) { Ok(_) => {} Err(BrzError::DivergedBranches) => { unreachable!(); } Err(e) => { return Err(e.into()); } } assert_eq!( local_tree.last_revision().unwrap(), main_branch.last_revision() ); } // At this point, we're either on the tip of the main branch or the tip of the resume // branch if let Some(resume_branch) = self.resume_branch.as_ref() { // If there's a resume branch at play, make sure it's derived from the main branch // *or* reset back to the main branch. log::debug!( "Pulling in missing revisions from main branch {:?}", main_branch.get_user_url() ); match local_tree.pull(main_branch.as_ref(), Some(false), None, None) { Err(BrzError::DivergedBranches) => { log::info!("restarting branch"); refreshed = true; self.resume_branch = None; self.resume_branch_additional_colocated_branches.clear(); match local_tree.pull(main_branch.as_ref(), Some(true), None, None) { Ok(_) => {} Err(BrzError::DivergedBranches) => { unreachable!(); } Err(e) => { return Err(e.into()); } } fetch_colocated( &local_tree.branch().controldir(), &main_branch.controldir(), &self .additional_colocated_branches .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(), )?; } Ok(_) => { fetch_colocated( &local_tree.branch().controldir(), &main_branch.controldir(), &self .additional_colocated_branches .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(), )?; if !self.resume_branch_additional_colocated_branches.is_empty() { fetch_colocated( &local_tree.branch().controldir(), &resume_branch.controldir(), &self .resume_branch_additional_colocated_branches .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(), )?; self.additional_colocated_branches .extend(self.resume_branch_additional_colocated_branches.clone()); } } Err(e) => { log::warn!("Failed to pull from main branch: {}", e); } } } else { fetch_colocated( &local_tree.branch().controldir(), &main_branch.controldir(), &self .additional_colocated_branches .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(), )?; } } self.state = Some(WorkspaceState { base_revid: local_tree.last_revision().unwrap(), local_tree, refreshed, main_colo_revid, tempdir: td, }); Ok(()) } /// Return the state of the workspace fn state(&self) -> &WorkspaceState { self.state.as_ref().unwrap() } /// Create a new workspace builder pub fn builder() -> WorkspaceBuilder { WorkspaceBuilder::default() } /// Return the main branch pub fn main_branch(&self) -> Option<&dyn Branch> { self.main_branch.as_deref() } /// Set the main branch pub fn set_main_branch(&mut self, branch: Box) -> Result<(), Error> { self.main_branch = Some(branch); Ok(()) } /// Return the cached branch pub fn local_tree(&self) -> &WorkingTree { &self.state().local_tree } /// Return whether the workspace has been refreshed /// /// In other words, whether the workspace has been reset to the main branch pub fn refreshed(&self) -> bool { self.state().refreshed } /// Return the resume branch pub fn resume_branch(&self) -> Option<&dyn Branch> { self.resume_branch.as_deref() } /// Return the path to the workspace pub fn path(&self) -> PathBuf { self.local_tree() .abspath(std::path::Path::new(".")) .unwrap() } /// Return whether there are changes since the main branch pub fn changes_since_main(&self) -> bool { Some(self.local_tree().branch().last_revision()) != self.main_branch().map(|b| b.last_revision()) } /// Return whether there are changes since the base revision pub fn changes_since_base(&self) -> bool { Some(self.local_tree().branch().last_revision()) != self.base_revid() } /// Return the base revision id pub fn base_revid(&self) -> Option { self.state.as_ref().map(|s| s.base_revid.clone()) } /// Have any branch changes at all been made? /// /// Includes changes that already existed in the resume branch pub fn any_branch_changes(&self) -> bool { self.changed_branches().iter().any(|(_, br, r)| br != r) } /// Return the additional colocated branches pub fn additional_colocated_branches(&self) -> HashMap { self.additional_colocated_branches .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect() } /// Return the branches that have changed pub fn changed_branches(&self) -> Vec<(String, Option, Option)> { let main_branch = self.main_branch(); let mut branches = vec![( main_branch .as_ref() .map_or_else(|| "".to_string(), |b| b.name().unwrap()), main_branch.map(|b| b.last_revision()), Some(self.local_tree().last_revision().unwrap()), )]; let local_controldir = self.local_tree().controldir(); for (from_name, to_name) in self.additional_colocated_branches().iter() { let to_revision = match local_controldir.open_branch(Some(to_name)) { Ok(b) => Some(b.last_revision()), Err(BrzError::NoColocatedBranchSupport) => continue, Err(BrzError::NotBranchError(..)) => None, Err(e) => { panic!("Unexpected error opening branch {}: {}", to_name, e); } }; let from_revision = self.main_colo_revid().get(from_name).cloned(); branches.push((from_name.to_string(), from_revision, to_revision)); } branches } /// Return the main colocated branch revision ids pub fn main_colo_revid(&self) -> HashMap { self.state().main_colo_revid.clone() } /// Return the basis tree pub fn base_tree(&self) -> Result, BrzError> { let base_revid = &self.state().base_revid; match self.state().local_tree.revision_tree(base_revid) { Ok(t) => Ok(t), Err(BrzError::NoSuchRevisionInTree(revid)) => Ok(Box::new( self.local_tree() .branch() .repository() .revision_tree(&revid)?, )), Err(e) => Err(e), } } /// Defer destroying the workspace, even if the Workspace is dropped pub fn defer_destroy(&mut self) -> std::path::PathBuf { let tempdir = self.state.as_mut().unwrap().tempdir.take().unwrap(); tempdir.into_path() } /// Publish the changes back to the main branch pub fn publish_changes( &self, target_branch: Option<&dyn Branch>, mode: crate::Mode, name: &str, get_proposal_description: impl FnOnce(DescriptionFormat, Option<&MergeProposal>) -> String, get_proposal_commit_message: Option) -> Option>, get_proposal_title: Option) -> Option>, forge: Option<&Forge>, allow_create_proposal: Option, labels: Option>, overwrite_existing: Option, existing_proposal: Option, reviewers: Option>, tags: Option>, derived_owner: Option<&str>, allow_collaboration: Option, stop_revision: Option<&RevisionId>, auto_merge: Option, ) -> Result { let main_branch = self.main_branch(); crate::publish::publish_changes( self.local_tree().branch().as_ref(), target_branch.or(main_branch).unwrap(), self.resume_branch(), mode, name, get_proposal_description, get_proposal_commit_message, get_proposal_title, forge, allow_create_proposal, labels, overwrite_existing, existing_proposal, reviewers, tags, derived_owner, allow_collaboration, stop_revision, auto_merge, ) } /// Propose the changes against the main branch pub fn propose( &self, name: &str, description: &str, target_branch: Option<&dyn Branch>, forge: Option, existing_proposal: Option, tags: Option>, labels: Option>, overwrite_existing: Option, commit_message: Option<&str>, allow_collaboration: Option, title: Option<&str>, allow_empty: Option, reviewers: Option>, owner: Option<&str>, auto_merge: Option, ) -> Result<(MergeProposal, bool), Error> { let main_branch = self.main_branch(); let target_branch = target_branch.or(main_branch).unwrap(); let forge = if let Some(forge) = forge { forge } else { breezyshim::forge::get_forge(target_branch)? }; crate::publish::propose_changes( self.local_tree().branch().as_ref(), target_branch, &forge, name, description, self.resume_branch(), existing_proposal, overwrite_existing, labels, commit_message, title, Some(self.inverse_additional_colocated_branches()), allow_empty, reviewers, tags, owner, None, allow_collaboration, auto_merge, ) .map_err(|e| e.into()) } /// Push a new derived branch pub fn push_derived( &self, name: &str, target_branch: Option<&dyn Branch>, forge: Option, tags: Option>, overwrite_existing: Option, owner: Option<&str>, ) -> Result<(Box, url::Url), Error> { let main_branch = self.main_branch(); let target_branch = target_branch.or(main_branch).unwrap(); let forge = if let Some(forge) = forge { forge } else { breezyshim::forge::get_forge(target_branch)? }; crate::publish::push_derived_changes( self.local_tree().branch().as_ref(), target_branch, &forge, name, overwrite_existing, owner, tags, None, ) .map_err(|e| e.into()) } /// Push the specified tags to the main branch pub fn push_tags(&self, tags: HashMap) -> Result<(), Error> { self.push(Some(tags)) } /// Push the changes back to the main branch pub fn push(&self, tags: Option>) -> Result<(), Error> { let main_branch = self.main_branch().unwrap(); let forge = match breezyshim::forge::get_forge(main_branch) { Ok(forge) => Some(forge), Err(breezyshim::error::Error::UnsupportedForge(e)) => { // We can't figure out what branch to resume from when there's no forge // that can tell us. log::warn!( "Unsupported forge ({}), will attempt to push to {}", e, crate::vcs::full_branch_url(main_branch), ); None } Err(e) => { return Err(e.into()); } }; crate::publish::push_changes( self.local_tree().branch().as_ref(), main_branch, forge.as_ref(), None, Some( self.inverse_additional_colocated_branches() .into_iter() .collect(), ), tags, None, ) .map_err(Into::into) } fn inverse_additional_colocated_branches(&self) -> Vec<(String, String)> { let mut result = vec![]; for (k, v) in self.additional_colocated_branches().iter() { result.push((v.to_string(), k.to_string())); } result } /// Show the diff between the base tree and the local tree pub fn show_diff( &self, outf: Box, old_label: Option<&str>, new_label: Option<&str>, ) -> Result<(), BrzError> { breezyshim::diff::show_diff_trees( self.base_tree()?.as_ref(), &self.local_tree().basis_tree()?, outf, old_label, new_label, ) } /// Destroy this workspace pub fn destroy(&mut self) -> Result<(), Error> { self.state = None; Ok(()) } } #[cfg(test)] mod tests { use super::*; use breezyshim::controldir::ControlDirFormat; #[test] fn test_create_workspace() { let mut ws = Workspace::builder().build().unwrap(); assert_eq!(ws.local_tree().branch().name().as_ref().unwrap(), ""); assert_eq!( ws.base_revid(), Some(breezyshim::revisionid::RevisionId::null()) ); // There are changes since the branch is created assert!(ws.changes_since_main()); assert!(!ws.changes_since_base()); assert_eq!( ws.changed_branches(), vec![( "".to_string(), None, Some(breezyshim::revisionid::RevisionId::null()) )] ); let revid = ws .local_tree() .build_commit() .message("test commit") .allow_pointless(true) .commit() .unwrap(); assert!(ws.changes_since_main()); assert!(ws.changes_since_base()); assert_eq!( ws.changed_branches(), vec![("".to_string(), None, Some(revid))] ); ws.destroy().unwrap(); } #[test] fn test_temporary() { let ws = Workspace::temporary().unwrap(); assert_eq!(ws.local_tree().branch().name().as_ref().unwrap(), ""); assert_eq!( ws.base_revid(), Some(breezyshim::revisionid::RevisionId::null()) ); // There are changes since the branch is created assert!(ws.changes_since_main()); assert!(!ws.changes_since_base()); assert_eq!( ws.changed_branches(), vec![( "".to_string(), None, Some(breezyshim::revisionid::RevisionId::null()) )] ); } #[test] fn test_nascent() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .dir(ws_dir) .build() .unwrap(); assert!(!ws.changes_since_main()); assert!(!ws.any_branch_changes()); assert!(!ws.changes_since_base()); ws.local_tree() .build_commit() .message("A change") .commit() .unwrap(); assert_eq!(ws.path(), ws.local_tree().basedir().join(".")); assert!(ws.changes_since_main()); assert!(ws.changes_since_base()); assert!(ws.any_branch_changes()); assert_eq!( vec![( "".to_string(), Some(breezyshim::revisionid::RevisionId::null()), Some(ws.local_tree().last_revision().unwrap()) )], ws.changed_branches() ); std::mem::drop(td); } #[test] fn test_without_main() { let td = tempfile::tempdir().unwrap(); let ws = Workspace::builder() .dir(td.path().to_path_buf()) .build() .unwrap(); assert!(ws.changes_since_main()); assert!(ws.any_branch_changes()); assert!(!ws.changes_since_base()); ws.local_tree() .build_commit() .message("A change") .commit() .unwrap(); assert!(ws.changes_since_main()); assert!(ws.changes_since_base()); assert!(ws.any_branch_changes()); assert_eq!( vec![( "".to_string(), None, Some(ws.local_tree().last_revision().unwrap()) )], ws.changed_branches() ); std::mem::drop(ws); std::mem::drop(td); } #[test] fn test_basic() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); let revid1 = origin .build_commit() .message("first commit") .commit() .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .dir(ws_dir) .build() .unwrap(); assert!(!ws.changes_since_main()); assert!(!ws.any_branch_changes()); assert!(!ws.changes_since_base()); ws.local_tree() .build_commit() .message("A change") .commit() .unwrap(); assert!(ws.changes_since_main()); assert!(ws.changes_since_base()); assert!(ws.any_branch_changes()); assert_eq!( vec![( "".to_string(), Some(revid1), Some(ws.local_tree().last_revision().unwrap()) )], ws.changed_branches() ); std::mem::drop(td); } #[test] fn test_cached_branch_up_to_date() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); let revid1 = origin .build_commit() .message("first commit") .commit() .unwrap(); let cached = origin .branch() .controldir() .sprout( url::Url::from_directory_path(td.path().join("cached")).unwrap(), None, None, None, None, ) .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .cached_branch(cached.open_branch(None).unwrap()) .dir(ws_dir) .build() .unwrap(); assert!(!ws.changes_since_main()); assert!(!ws.any_branch_changes()); assert!(!ws.changes_since_base()); assert_eq!(ws.local_tree().last_revision().unwrap(), revid1); std::mem::drop(td); } #[test] fn test_cached_branch_out_of_date() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); origin .build_commit() .message("first commit") .commit() .unwrap(); let cached = origin .branch() .controldir() .sprout( url::Url::from_directory_path(td.path().join("cached")).unwrap(), None, None, None, None, ) .unwrap(); let revid2 = origin .build_commit() .message("second commit") .commit() .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .cached_branch(cached.open_branch(None).unwrap()) .dir(ws_dir) .build() .unwrap(); assert!(!ws.changes_since_main()); assert!(!ws.any_branch_changes()); assert!(!ws.changes_since_base()); assert_eq!(ws.local_tree().last_revision().unwrap(), revid2); std::mem::drop(td); } fn commit_on_colo( controldir: &ControlDir, to_location: &std::path::Path, message: &str, ) -> RevisionId { let colo_branch = controldir.create_branch(Some("colo")).unwrap(); let colo_checkout = colo_branch.create_checkout(to_location).unwrap(); colo_checkout .build_commit() .message(message) .commit() .unwrap() } #[test] fn test_colocated() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); let revid1 = origin.build_commit().message("main").commit().unwrap(); let colo_revid1 = commit_on_colo( &origin.branch().controldir(), &td.path().join("colo"), "Another", ); assert_eq!(origin.branch().last_revision(), revid1); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .dir(ws_dir) .additional_colocated_branches( vec![("colo".to_string(), "colo".to_string())] .into_iter() .collect(), ) .build() .unwrap(); assert!(!ws.changes_since_main()); assert!(!ws.any_branch_changes()); assert!(!ws.changes_since_base()); ws.local_tree() .build_commit() .message("A change") .commit() .unwrap(); assert!(ws.changes_since_main()); assert!(ws.changes_since_base()); assert!(ws.any_branch_changes()); assert_eq!( vec![ ( "".to_string(), Some(revid1), Some(ws.local_tree().last_revision().unwrap()) ), ( "colo".to_string(), Some(colo_revid1.clone()), Some(colo_revid1.clone()) ), ], ws.changed_branches() ); std::mem::drop(td); } #[test] fn test_resume_continue() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); let revid1 = origin .build_commit() .message("first commit") .commit() .unwrap(); let resume = origin .branch() .controldir() .sprout( url::Url::from_directory_path(td.path().join("resume")).unwrap(), None, None, None, None, ) .unwrap(); let resume_tree = resume.open_workingtree().unwrap(); let resume_revid1 = resume_tree .build_commit() .message("resume") .commit() .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .resume_branch(resume_tree.branch()) .dir(ws_dir) .build() .unwrap(); assert!(ws.changes_since_main()); assert!(ws.any_branch_changes()); assert!(!ws.refreshed()); assert!(!ws.changes_since_base()); assert_eq!(ws.local_tree().last_revision().unwrap(), resume_revid1); assert_eq!( vec![("".to_string(), Some(revid1), Some(resume_revid1))], ws.changed_branches() ); std::mem::drop(td); } #[test] fn test_resume_discard() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); origin .build_commit() .message("first commit") .commit() .unwrap(); let resume = origin .branch() .controldir() .sprout( url::Url::from_directory_path(td.path().join("resume")).unwrap(), None, None, None, None, ) .unwrap(); let revid2 = origin .build_commit() .message("second commit") .commit() .unwrap(); let resume_tree = resume.open_workingtree().unwrap(); resume_tree .build_commit() .message("resume") .commit() .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .resume_branch(resume_tree.branch()) .dir(ws_dir) .build() .unwrap(); assert!(!ws.changes_since_main()); assert!(!ws.any_branch_changes()); assert!(ws.refreshed()); assert!(!ws.changes_since_base()); assert_eq!(ws.local_tree().last_revision().unwrap(), revid2); assert_eq!( vec![("".to_string(), Some(revid2.clone()), Some(revid2.clone()))], ws.changed_branches() ); std::mem::drop(td); } #[test] fn test_resume_continue_with_unchanged_colocated() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); let revid1 = origin .build_commit() .message("first commit") .commit() .unwrap(); let colo_revid1 = commit_on_colo( &origin.branch().controldir(), &td.path().join("colo"), "First colo", ); let resume = origin .branch() .controldir() .sprout( url::Url::from_directory_path(td.path().join("resume")).unwrap(), None, None, None, None, ) .unwrap(); let resume_tree = resume.open_workingtree().unwrap(); let resume_revid1 = resume_tree .build_commit() .message("resume") .commit() .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .resume_branch(resume_tree.branch()) .dir(ws_dir) .additional_colocated_branches( vec![("colo".to_string(), "colo".to_string())] .into_iter() .collect(), ) .build() .unwrap(); assert!(ws.changes_since_main()); assert!(ws.any_branch_changes()); assert!(!ws.refreshed()); assert!(!ws.changes_since_base()); assert_eq!(ws.local_tree().last_revision().unwrap(), resume_revid1); assert_eq!( vec![ ("".to_string(), Some(revid1), Some(resume_revid1)), ( "colo".to_string(), Some(colo_revid1.clone()), Some(colo_revid1.clone()) ), ], ws.changed_branches() ); std::mem::drop(td); } #[test] fn test_resume_discard_with_unchanged_colocated() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); origin .build_commit() .message("first commit") .commit() .unwrap(); let colo_revid1 = commit_on_colo( &origin.branch().controldir(), &td.path().join("colo"), "First colo", ); let resume = origin .branch() .controldir() .sprout( url::Url::from_directory_path(td.path().join("resume")).unwrap(), None, None, None, None, ) .unwrap(); commit_on_colo( &resume, &td.path().join("resume-colo"), "First colo on resume", ); let revid2 = origin .build_commit() .message("second commit") .commit() .unwrap(); let resume_tree = resume.open_workingtree().unwrap(); resume_tree .build_commit() .message("resume") .commit() .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let ws = Workspace::builder() .main_branch(origin.branch()) .resume_branch(resume_tree.branch()) .dir(ws_dir) .additional_colocated_branches( vec![("colo".to_string(), "colo".to_string())] .into_iter() .collect(), ) .build() .unwrap(); assert!(!ws.changes_since_main()); assert!(!ws.any_branch_changes()); assert!(ws.refreshed()); assert!(!ws.changes_since_base()); assert_eq!(ws.local_tree().last_revision().unwrap(), revid2); assert_eq!( vec![ ("".to_string(), Some(revid2.clone()), Some(revid2.clone())), ( "colo".to_string(), Some(colo_revid1.clone()), Some(colo_revid1.clone()) ), ], ws.changed_branches() ); std::mem::drop(td); } #[test] fn test_defer_destroy() { let td = tempfile::tempdir().unwrap(); let origin = breezyshim::controldir::create_standalone_workingtree( &td.path().join("origin"), &ControlDirFormat::default(), ) .unwrap(); origin .build_commit() .message("first commit") .commit() .unwrap(); let ws_dir = td.path().join("ws"); std::fs::create_dir(&ws_dir).unwrap(); let mut ws = Workspace::builder() .main_branch(origin.branch()) .dir(ws_dir) .build() .unwrap(); let tempdir = ws.defer_destroy(); assert!(tempdir.exists()); std::mem::drop(ws); assert!(tempdir.exists()); std::mem::drop(td); } } silver-platter-0.5.44/src/bin/debian-svp.rs0000644000000000000000000007067114721061524015461 0ustar00use breezyshim::workingtree; use breezyshim::workspace::{check_clean_tree, reset_tree}; use clap::{Args, Parser, Subcommand}; use log::{error, info}; use silver_platter::candidates::Candidates; use silver_platter::debian::codemod::{script_runner, CommandResult}; use silver_platter::proposal::{MergeProposal, MergeProposalStatus}; use silver_platter::publish::Error as PublishError; use silver_platter::CodemodResult; use silver_platter::Mode; use std::collections::HashMap; use std::io::Write; use std::path::Path; #[derive(Parser)] #[command(author, version, about, long_about = None)] #[command(propagate_version = true)] struct Cli { #[command(subcommand)] command: Commands, #[arg(short, long)] debug: bool, } #[derive(Subcommand)] enum Commands { /// List all forges Forges {}, /// Login to a forge Login { url: url::Url, }, /// List merge proposals by the current user Proposals { // Status is one of "open", "merged" or "closed" #[arg(short, long, default_value = "open")] status: Option, }, Run(RunArgs), /// Apply a script to make a change in an existing local checkout Apply { /// Path to script to run command: Option, /// Show diff of generated changes #[arg(long)] diff: bool, /// Command pending changes after script #[arg(long)] commit_pending: Option, /// Build package to verify it #[arg(long)] build_verify: bool, /// Build command to use when verifying build #[arg(long, default_value(silver_platter::debian::DEFAULT_BUILDER))] builder: String, /// Store built Debian files in specific directory (with --build-verify) #[arg(long)] build_target_dir: Option, /// Install built packages (implies --build-verify) #[arg(long)] install: bool, /// Report context on success #[arg(long)] dump_context: bool, /// Recipe to use #[arg(long)] recipe: Option, /// Don't update changelog #[arg(long)] no_update_changelog: bool, /// Do update changelog #[arg(long)] update_changelog: bool, }, #[clap(subcommand)] Batch(BatchArgs), UploadPending { /// List of acceptable GPG keys #[arg(long)] acceptable_keys: Option>, /// Verify GPG signatures on commit #[arg(long)] gpg_verification: bool, /// Minimum age of the last commit, in days #[arg(long)] min_commit_age: Option, /// Show diff #[arg(long)] diff: bool, /// Build command #[arg(long, default_value_t = format!("{} --source --source-only-changes --debbuildopt=-v$(LAST_VERSION)", silver_platter::debian::DEFAULT_BUILDER))] builder: String, /// Select all packages maintained by specified maintainer. #[arg(long, conflicts_with = "packages")] maintainer: Option>, /// Use vcswatch to determine what packages need uploading. #[arg(long)] vcswatch: bool, /// Ignore source package #[arg(long)] exclude: Option>, /// Only process packages with autopkgtest #[arg(long)] autopkgtest_only: bool, /// Require that all new commits are from specified committers #[arg(long)] allowed_committer: Option>, /// Randomize order packages are processed in. #[arg(long)] shuffle: bool, /// Command to verify whether upload is necessary. Should return 1 to decline, 0 to upload. #[arg(long)] verify_command: Option, /// APT repository to use. Defaults to locally configured. #[arg(long, env = "APT_REPOSITORY")] apt_repository: Option, /// APT repository key to use for validation, if --apt-repository is set. #[arg(long, env = "APT_REPOSITORY_KEY")] apt_repository_key: Option, /// Packages to upload packages: Vec, }, } /// Run a script to make a change, and publish (propose/push/etc) it #[derive(Args)] struct RunArgs { url: Option, /// Path to script to run #[arg(long)] command: Option, /// Owner for derived branches #[arg(long)] derived_owner: Option, /// Refresh changes if branch already exists #[arg(long)] refresh: bool, /// Label to attach #[arg(long)] label: Option>, /// Proposed branch name #[arg(long)] branch: Option, /// Show diff of generated changes #[arg(long)] diff: bool, /// Mode for pushing #[arg(long)] push: Option, /// Commit pending changes after script /// One of: ["yes", "no", "auto"] #[arg(long, default_value = "auto")] commit_pending: Option, /// Command to verify changes #[arg(long)] verify_command: Option, /// Recipe to use #[arg(long)] recipe: Option, /// File with candidate list #[arg(long)] candidates: Option, /// Mode for publishing #[arg(long)] mode: Option, /// Don't update changelog #[arg(long)] no_update_changelog: bool, /// Do update changelog #[arg(long)] update_changelog: bool, /// Build package to verify it #[arg(long)] build_verify: bool, /// Build command to use when verifying build #[arg(long, default_value(silver_platter::debian::DEFAULT_BUILDER))] builder: String, /// Store built Debian files in specific directory (with --build-verify) #[arg(long)] build_target_dir: Option, /// Install built packages (implies --build-verify) #[arg(long)] install: bool, } /// Operate on multiple repositories at once #[derive(Subcommand)] enum BatchArgs { Generate { /// Recipe to use #[arg(long)] recipe: Option, /// File with candidate list #[arg(long)] candidates: Option, /// Directory to run in directory: Option, }, Publish { /// Directory to run in directory: std::path::PathBuf, /// Specific entry to publish name: Option, #[arg(long)] /// Overwrite existing merge requests overwrite: bool, }, Status { /// Directory to run in directory: std::path::PathBuf, /// Specific entry to publish codebase: Option, }, Diff { /// Directory to run in directory: std::path::PathBuf, /// Specific entry to publish codebase: String, }, /// Refresh changes Refresh { /// Directory to run in directory: std::path::PathBuf, /// Specific entry to publish codebase: Option, }, } fn run(args: &RunArgs) -> i32 { let mut extra_env = HashMap::new(); if let Some(recipe) = &args.recipe { extra_env.insert( "RECIPEDIR".to_string(), recipe.parent().unwrap().to_str().unwrap().to_string(), ); } let recipe = args .recipe .as_ref() .map(|recipe| silver_platter::recipe::Recipe::from_path(recipe.as_path()).unwrap()); let mut urls = vec![]; if let Some(url) = args.url.as_ref() { urls.push(url.clone()); } if let Some(candidates) = args.candidates.as_ref() { let candidates = Candidates::from_path(candidates.as_path()).unwrap(); urls.extend(candidates.iter().map(|c| c.url.clone())); } let update_changelog = if args.update_changelog { Some(true) } else if args.no_update_changelog { Some(false) } else { None }; let commit_pending = if let Some(commit_pending) = args.commit_pending { commit_pending } else if let Some(recipe) = &recipe { recipe.commit_pending } else { silver_platter::CommitPending::Auto }; let command = if let Some(command) = args.command.as_ref() { shlex::split(command.as_str()).unwrap() } else if let Some(recipe) = &recipe { recipe.command.as_ref().unwrap().argv() } else { error!("No command specified"); return 1; }; let branch = if let Some(branch) = args.branch.as_ref() { branch.clone() } else if let Some(recipe) = recipe.as_ref() { recipe.name.clone().unwrap() } else { silver_platter::derived_branch_name(command.first().unwrap()).to_string() }; let mode = if let Some(mode) = args.mode { mode } else if let Some(recipe) = &recipe { recipe.mode.unwrap() } else { silver_platter::Mode::Propose }; let mut refresh = args.refresh; if let Some(ref recipe) = recipe { if recipe.resume.is_some() { refresh = true; } } let recipe_ref = recipe.as_ref(); let allow_create_proposal = |result: &CommandResult| -> bool { if let Some(value) = result.value.as_ref() { if let Some(recipe) = recipe_ref { if let Some(merge_request) = recipe.merge_request.as_ref() { if let Some(propose_threshold) = merge_request.propose_threshold { return *value >= propose_threshold; } } } } true }; let recipe_ref = recipe.as_ref(); let get_commit_message = |result: &CommandResult, existing_proposal: Option<&MergeProposal>| { if let Some(recipe) = recipe_ref { if let Some(merge_request) = recipe.merge_request.as_ref() { return merge_request .render_commit_message(&result.tera_context()) .unwrap(); } } if let Some(existing_proposal) = existing_proposal.as_ref() { return existing_proposal.get_commit_message().unwrap(); } None }; let recipe_ref = recipe.as_ref(); let get_title = |result: &CommandResult, existing_proposal: Option<&MergeProposal>| { if let Some(recipe) = recipe_ref { if let Some(merge_request) = recipe.merge_request.as_ref() { return merge_request.render_title(&result.tera_context()).unwrap(); } } if let Some(existing_proposal) = existing_proposal { return existing_proposal.get_title().unwrap(); } None }; let get_description = |result: &CommandResult, description_format, _existing_proposal: Option<&MergeProposal>| -> String { if let Some(recipe) = recipe.as_ref() { if let Some(merge_request) = recipe.merge_request.as_ref() { let description = merge_request .render_description(description_format, &result.tera_context()) .unwrap(); if let Some(description) = description { return description; } } } return result.description.clone(); }; let mut retcode = 0; let labels_ref = args .label .as_ref() .map(|labels| labels.iter().map(|s| s.as_str()).collect::>()); for url in urls { let result = silver_platter::debian::run::apply_and_publish( &url, branch.as_str(), command .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), mode, commit_pending, labels_ref.as_deref(), args.diff, args.derived_owner.as_deref(), refresh, Some(allow_create_proposal), Some(get_commit_message), Some(get_title), get_description, update_changelog, args.build_verify, args.build_target_dir.clone(), Some(args.builder.clone()), args.install, Some(extra_env.clone()), ); retcode = std::cmp::max(retcode, result) } retcode } pub fn publish_entry( batch: &mut silver_platter::batch::Batch, name: &str, refresh: bool, overwrite: Option, ) -> bool { let batch_name = batch.name.clone(); let entry = batch.get_mut(name).unwrap(); let publish_result = match entry.publish(&batch_name, refresh, overwrite) { Ok(publish_result) => publish_result, Err(PublishError::EmptyMergeProposal) => { info!("No changes left"); batch.remove(name).unwrap(); return true; } Err(PublishError::UnrelatedBranchExists) => { return false; } Err(e) => { error!("Failed to publish {}: {}", name, e); return false; } }; match publish_result.mode { Mode::Push => { batch.remove(name).unwrap(); } Mode::Propose => { entry.proposal_url = Some(publish_result.proposal.unwrap().url().unwrap()); } Mode::PushDerived => { batch.remove(name).unwrap(); } _ => { unreachable!(); } } true } pub fn batch_publish( directory: &Path, codebase: Option<&str>, refresh: bool, overwrite: Option, ) -> i32 { let mut batch = match silver_platter::batch::load_batch_metadata(directory) { Ok(Some(batch)) => batch, Ok(None) => { info!("No batch.yaml found in {}", directory.display()); return 1; } Err(e) => { error!("Failed to load batch.yaml: {}", e); return 1; } }; let mut errors = 0; if let Some(codebase) = codebase { if publish_entry(&mut batch, codebase, refresh, overwrite) { silver_platter::batch::save_batch_metadata(directory, &batch).unwrap(); } else { error!("Failed to publish {}", codebase); errors = 1; } } else { let names = batch.work.keys().cloned().collect::>(); for name in names { if !publish_entry(&mut batch, name.as_str(), refresh, overwrite) { errors += 1; } } silver_platter::batch::save_batch_metadata(directory, &batch).unwrap(); } if batch.work.is_empty() { info!( "No work left in batch.yaml; you can now remove {}", directory.display() ); } if errors > 0 { 1 } else { 0 } } fn login(url: &url::Url) -> i32 { let lp_uris = breezyshim::launchpad::uris().unwrap(); let forge = if url.host_str() == Some("github.com") { "github" } else if lp_uris.iter().any(|(_key, root)| { url.host_str() == Some(root) || url.host_str() == Some(root.trim_end_matches('/')) }) { "launchpad" } else { "gitlab" }; match forge { "gitlab" => { breezyshim::gitlab::login(url).unwrap(); } "github" => { breezyshim::github::login().unwrap(); } "launchpad" => { breezyshim::launchpad::login(url); } _ => { panic!("Unknown forge {}", forge); } } 0 } pub fn batch_refresh(directory: &Path, codebase: Option<&str>) -> i32 { let directory = directory.canonicalize().unwrap(); let mut batch = match silver_platter::batch::load_batch_metadata(&directory) { Ok(Some(batch)) => batch, Ok(None) => { info!("No batch.yaml found in {}", directory.display()); return 1; } Err(e) => { error!( "Failed to load batch metadata from {}: {}", directory.display(), e ); return 1; } }; let mut errors = 0; if let Some(codebase) = codebase { let entry = batch.work.get_mut(codebase).unwrap(); if entry.refresh(&batch.recipe, None).is_err() { errors += 1; } } else { let names = batch.work.keys().cloned().collect::>(); for name in names { let entry = batch.work.get_mut(name.as_str()).unwrap(); if entry.refresh(&batch.recipe, None).is_err() { errors += 1; } } } match silver_platter::batch::save_batch_metadata(&directory, &batch) { Ok(_) => {} Err(e) => { error!( "Failed to save batch metadata to {}: {}", directory.display(), e ); return 1; } } if batch.work.is_empty() { info!( "No work left in batch.yaml; you can now remove {}", directory.display() ); } if errors > 0 { 1 } else { 0 } } fn main() -> Result<(), i32> { let cli = Cli::parse(); env_logger::builder() .format(|buf, record| writeln!(buf, "{}", record.args())) .filter( None, if cli.debug { log::LevelFilter::Debug } else { log::LevelFilter::Info }, ) .init(); breezyshim::init(); breezyshim::plugin::load_plugins(); match &cli.command { Commands::Forges {} => { for instance in breezyshim::forge::iter_forge_instances() { println!("{} ({})", instance.base_url(), instance.forge_kind()); } Ok(()) } Commands::Login { url } => match login(url) { 0 => Ok(()), e => Err(e), }, Commands::Proposals { status } => { let statuses = status.as_ref().map(|status| vec![*status]); for (_forge, proposal) in silver_platter::proposal::iter_all_mps(statuses) { println!("{}", proposal.url().unwrap()); } Ok(()) } Commands::Run(args) => match run(args) { 0 => Ok(()), e => Err(e), }, Commands::Apply { command, diff, commit_pending, install, mut build_verify, ref build_target_dir, builder, dump_context, no_update_changelog, update_changelog, recipe, } => { if *install { build_verify = true; } let recipe = recipe .as_ref() .map(|recipe| silver_platter::recipe::Recipe::from_path(recipe).unwrap()); let commit_pending = if let Some(commit_pending) = commit_pending { *commit_pending } else if let Some(recipe) = &recipe { recipe.commit_pending } else { silver_platter::CommitPending::Auto }; let command = if let Some(command) = command.as_ref() { shlex::split(command.as_str()).unwrap() } else if let Some(recipe) = &recipe { recipe.command.as_ref().unwrap().argv() } else { error!("No command specified"); return Err(1); }; let (local_tree, subpath) = workingtree::open_containing(Path::new(".")).unwrap(); check_clean_tree( &local_tree, &local_tree.basis_tree().unwrap(), subpath.as_path(), ) .unwrap(); let update_changelog = if *update_changelog { Some(true) } else if *no_update_changelog { Some(false) } else { None }; let result = match script_runner( &local_tree, command .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), subpath.as_path(), commit_pending, None, None, None, std::process::Stdio::inherit(), update_changelog, ) { Ok(result) => result, Err(err) => { error!("Failed: {}", err); reset_tree(&local_tree, None, Some(subpath.as_path())).unwrap(); return Err(1); } }; let mut td = None; let mut build_target_dir = build_target_dir.clone(); if build_verify { if build_target_dir.is_none() { td = Some(tempfile::tempdir().unwrap()); build_target_dir = td.as_ref().map(|td| td.path().to_owned()); } silver_platter::debian::build( &local_tree, &subpath, Some(builder), build_target_dir.as_deref(), ) .unwrap(); } info!("Succeeded: {} ", result.description); if *diff { let old_tree = local_tree.revision_tree(&result.old_revision).unwrap(); let new_tree = local_tree.revision_tree(&result.new_revision).unwrap(); breezyshim::diff::show_diff_trees( old_tree.as_ref(), new_tree.as_ref(), Box::new(std::io::stdout()), Some("old/"), Some("new/"), ) .unwrap(); } if *install { silver_platter::debian::install_built_package( &local_tree, subpath.as_path(), build_target_dir.as_ref().unwrap(), ) .unwrap(); } if let Some(td) = td.take() { td.close().unwrap(); } if *dump_context { let context = result.context.unwrap(); println!("{}", serde_json::to_string_pretty(&context).unwrap()); } Ok(()) } Commands::UploadPending { acceptable_keys, gpg_verification, min_commit_age, diff, maintainer, builder, autopkgtest_only, vcswatch, shuffle, exclude, verify_command, allowed_committer, apt_repository, apt_repository_key, packages, } => silver_platter::debian::uploader::main( packages.clone(), acceptable_keys.clone(), *gpg_verification, *min_commit_age, *diff, builder.clone(), maintainer.clone(), *vcswatch, exclude.clone(), *autopkgtest_only, allowed_committer.clone(), cli.debug, *shuffle, verify_command.clone(), apt_repository.clone(), apt_repository_key.clone(), ), Commands::Batch(args) => match args { BatchArgs::Generate { recipe, candidates, directory, } => { let recipe = if let Some(recipe) = recipe { silver_platter::recipe::Recipe::from_path(recipe.as_path()).unwrap() } else { panic!("No recipe specified"); }; let candidates = if let Some(candidate_list) = candidates { Candidates::from_path(candidate_list.as_path()).unwrap() } else { Candidates::new() }; let directory = if let Some(directory) = directory.as_ref() { directory.clone() } else { info!("Using output directory: {}", recipe.name.as_ref().unwrap()); std::path::PathBuf::from(recipe.name.clone().unwrap()) }; silver_platter::batch::Batch::from_recipe( &recipe, candidates.iter(), directory.as_path(), None, ) .unwrap(); info!("Now, review the patches under {}, edit {}/batch.yaml as appropriate and then run \"svp batch publish {}\"", directory.display(), directory.display(), directory.display()); Ok(()) } BatchArgs::Publish { directory, name, overwrite, } => { let overwrite = if *overwrite { Some(true) } else { None }; let ret = batch_publish(directory.as_path(), name.as_deref(), false, overwrite); info!( "To see the status of open merge requests, run: \"svp batch status {}\"", directory.display() ); match ret { 0 => Ok(()), e => Err(e), } } BatchArgs::Status { directory, codebase, } => { let batch = match silver_platter::batch::load_batch_metadata(directory) { Ok(Some(batch)) => batch, Ok(None) => { info!("No batch.yaml found in {}", directory.display()); return Err(1); } Err(e) => { error!("Failed to load batch.yaml: {}", e); return Err(1); } }; if let Some(codebase) = codebase { let entry = batch.work.get(codebase).unwrap(); info!("{}: {}", codebase, entry.status()); } else { for (name, entry) in batch.work.iter() { info!("{}: {}", name, entry.status()); } } Ok(()) } BatchArgs::Diff { directory, codebase, } => { let batch = match silver_platter::batch::load_batch_metadata(directory) { Ok(Some(batch)) => batch, Ok(None) => { info!("No batch.yaml found in {}", directory.display()); return Err(1); } Err(e) => { error!("Failed to load batch.yaml: {}", e); return Err(1); } }; let entry = batch.work.get(codebase.as_str()).unwrap(); let main_branch = match entry.target_branch() { Ok(branch) => branch, Err(e) => { error!("Failed to open branch: {}", e); return Err(1); } }; let local_branch = match entry.local_branch() { Ok(branch) => branch, Err(e) => { error!("Failed to open branch: {}", e); return Err(1); } }; let repository = local_branch.repository(); let main_revision = main_branch.last_revision(); repository .fetch(&main_branch.repository(), Some(&main_revision)) .unwrap(); let main_tree = repository.revision_tree(&main_revision).unwrap(); breezyshim::diff::show_diff_trees( &main_tree, &local_branch.basis_tree().unwrap(), Box::new(std::io::stdout()), Some("old/"), Some("new/"), ) .unwrap(); Err(1) } BatchArgs::Refresh { directory, codebase, } => { let ret = batch_refresh(directory.as_path(), codebase.as_deref()); match ret { 0 => Ok(()), e => Err(e), } } }, } } silver-platter-0.5.44/src/bin/svp.rs0000644000000000000000000006265314721061524014242 0ustar00use breezyshim::workingtree; use breezyshim::workspace::{check_clean_tree, reset_tree}; use clap::{Args, Parser, Subcommand}; use log::{error, info}; use silver_platter::candidates::Candidates; use silver_platter::codemod::{script_runner, CommandResult}; use silver_platter::proposal::{MergeProposal, MergeProposalStatus}; use silver_platter::publish::Error as PublishError; use silver_platter::CodemodResult; use silver_platter::Mode; use std::io::Write; use std::path::Path; #[derive(Parser)] #[command(author, version, about, long_about = None)] #[command(propagate_version = true)] struct Cli { #[command(subcommand)] command: Commands, #[arg(short, long)] debug: bool, } #[derive(Subcommand)] enum Commands { /// List all forges Forges {}, /// Login to a forge Login { url: url::Url, }, /// List merge proposals by the current user Proposals { // Status is one of "open", "merged" or "closed" #[arg(short, long, default_value = "open")] status: Option, }, Run(RunArgs), /// Apply a script to make a change in an existing local checkout Apply { /// Path to script to run command: Option, /// Show diff of generated changes #[arg(long)] diff: bool, /// Command pending changes after script #[arg(long)] commit_pending: Option, /// Command to verify changes #[arg(long)] verify_command: Option, /// Recipe to use #[arg(long)] recipe: Option, }, #[clap(subcommand)] Batch(BatchArgs), } /// Run a script to make a change, and publish (propose/push/etc) it #[derive(Args)] struct RunArgs { url: Option, /// Path to script to run #[arg(long)] command: Option, /// Owner for derived branches #[arg(long)] derived_owner: Option, /// Refresh changes if branch already exists #[arg(long)] refresh: bool, /// Label to attach #[arg(long)] label: Option>, /// Proposed branch name #[arg(long)] branch: Option, /// Show diff of generated changes #[arg(long)] diff: bool, /// Mode for pushing #[arg(long)] push: Option, /// Commit pending changes after script /// One of: ["yes", "no", "auto"] #[arg(long, default_value = "auto")] commit_pending: Option, /// Command to verify changes #[arg(long)] verify_command: Option, /// Recipe to use #[arg(long)] recipe: Option, /// File with candidate list #[arg(long)] candidates: Option, /// Mode for publishing #[arg(long)] mode: Option, } /// Operate on multiple repositories at once #[derive(Subcommand)] enum BatchArgs { /// Generate a batch Generate { /// Recipe to use #[arg(long)] recipe: Option, /// File with candidate list #[arg(long)] candidates: Option, /// Directory to run in directory: Option, }, /// Publish a batch or specific entry Publish { /// Directory to run in directory: std::path::PathBuf, /// Specific entry to publish name: Option, /// Whether to overwrite existing branches #[arg(long)] overwrite: bool, /// Refresh changes #[arg(long)] refresh: bool, }, /// Show status of a batch or specific entry Status { /// Directory to run in directory: std::path::PathBuf, /// Specific entry to publish codebase: Option, }, /// Show diff of a specific entry in a batch Diff { /// Directory to run in directory: std::path::PathBuf, /// Specific entry to publish codebase: String, }, /// Refresh changes Refresh { /// Directory to run in directory: std::path::PathBuf, /// Specific entry to publish codebase: Option, }, } fn run(args: &RunArgs) -> i32 { let mut extra_env = std::collections::HashMap::new(); let recipe = args .recipe .as_ref() .map(|recipe| silver_platter::recipe::Recipe::from_path(recipe.as_path()).unwrap()); if let Some(recipe) = &args.recipe { extra_env.insert( "RECIPEDIR".to_string(), recipe.parent().unwrap().to_str().unwrap().to_string(), ); } let mut urls = vec![]; if let Some(url) = args.url.as_ref() { urls.push(url.clone()); } if let Some(candidates) = args.candidates.as_ref() { let candidates = Candidates::from_path(candidates.as_path()).unwrap(); urls.extend(candidates.iter().map(|c| c.url.clone())); } let commit_pending = if let Some(commit_pending) = args.commit_pending { commit_pending } else if let Some(recipe) = &recipe { recipe.commit_pending } else { silver_platter::CommitPending::Auto }; let command = if let Some(command) = args.command.as_ref() { shlex::split(command.as_str()).unwrap() } else if let Some(recipe) = &recipe { recipe.command.as_ref().unwrap().argv() } else { error!("No command specified"); return 1; }; let branch = if let Some(branch) = args.branch.as_ref() { branch.clone() } else if let Some(recipe) = recipe.as_ref() { recipe.name.clone().unwrap() } else { silver_platter::derived_branch_name(command.first().unwrap()).to_string() }; let mode = if let Some(mode) = args.mode { mode } else if let Some(recipe) = &recipe { recipe.mode.unwrap() } else { silver_platter::Mode::Propose }; let mut refresh = args.refresh; if let Some(ref recipe) = recipe { if recipe.resume.is_some() { refresh = true; } } let recipe_ref = recipe.as_ref(); let allow_create_proposal = |result: &CommandResult| -> bool { if let Some(value) = result.value.as_ref() { if let Some(recipe) = recipe_ref { if let Some(merge_request) = recipe.merge_request.as_ref() { if let Some(propose_threshold) = merge_request.propose_threshold { return *value >= propose_threshold; } } } } true }; let recipe_ref = recipe.as_ref(); let get_commit_message = |result: &CommandResult, existing_proposal: Option<&MergeProposal>| { if let Some(recipe) = recipe_ref { if let Some(merge_request) = recipe.merge_request.as_ref() { return merge_request .render_commit_message(&result.tera_context()) .unwrap(); } } if let Some(existing_proposal) = existing_proposal.as_ref() { return existing_proposal.get_commit_message().unwrap(); } None }; let recipe_ref = recipe.as_ref(); let get_title = |result: &CommandResult, existing_proposal: Option<&MergeProposal>| { if let Some(recipe) = recipe_ref { if let Some(merge_request) = recipe.merge_request.as_ref() { return merge_request.render_title(&result.tera_context()).unwrap(); } } if let Some(existing_proposal) = existing_proposal { return existing_proposal.get_title().unwrap(); } None }; let get_description = |result: &CommandResult, description_format, existing_proposal: Option<&MergeProposal>| -> String { if let Some(recipe) = recipe.as_ref() { if let Some(merge_request) = recipe.merge_request.as_ref() { let description = merge_request .render_description(description_format, &result.tera_context()) .unwrap(); if let Some(description) = description { return description; } } } if let Some(description) = result.description.as_ref() { return description.clone(); } if let Some(existing_proposal) = existing_proposal { return existing_proposal.get_description().unwrap().unwrap(); } panic!("No description available"); }; let mut retcode = 0; let labels_ref = args .label .as_ref() .map(|labels| labels.iter().map(|s| s.as_str()).collect::>()); for url in urls { let result = silver_platter::run::apply_and_publish( &url, branch.as_str(), command .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), mode, commit_pending, labels_ref.as_deref(), args.diff, args.verify_command.as_deref(), args.derived_owner.as_deref(), refresh, Some(allow_create_proposal), Some(get_commit_message), Some(get_title), get_description, Some(extra_env.clone()), ); retcode = std::cmp::max(retcode, result) } retcode } fn publish_entry( batch: &mut silver_platter::batch::Batch, name: &str, refresh: bool, overwrite: bool, ) -> bool { let batch_name = batch.name.clone(); let entry = batch.get_mut(name).unwrap(); let overwrite = if overwrite { Some(true) } else { None }; let publish_result = match entry.publish(&batch_name, refresh, overwrite) { Ok(publish_result) => publish_result, Err(PublishError::EmptyMergeProposal) => { info!("No changes left"); match batch.remove(name) { Ok(_) => {} Err(e) => { error!("Failed to remove {}: {}", name, e); } } return true; } Err(PublishError::UnrelatedBranchExists) => { error!("An unrelated branch exists. Remove it or use --overwrite."); return false; } Err(e) => { error!("Failed to publish {}: {}", name, e); return false; } }; match publish_result.mode { Mode::Push => { batch.remove(name).unwrap(); } Mode::Propose => { entry.proposal_url = Some(publish_result.proposal.unwrap().url().unwrap()); } Mode::PushDerived => { batch.remove(name).unwrap(); } _ => { unreachable!(); } } true } pub fn batch_refresh(directory: &Path, codebase: Option<&str>) -> Result<(), i32> { let directory = directory.canonicalize().unwrap(); let mut batch = match silver_platter::batch::load_batch_metadata(&directory) { Ok(Some(batch)) => batch, Ok(None) => { info!("No batch.yaml found in {}", directory.display()); return Err(1); } Err(e) => { error!( "Failed to load batch metadata from {}: {}", directory.display(), e ); return Err(1); } }; let mut errors = 0; if let Some(codebase) = codebase { let entry = batch.work.get_mut(codebase).unwrap(); if entry.refresh(&batch.recipe, None).is_err() { errors += 1; } } else { let names = batch.work.keys().cloned().collect::>(); for name in names { let entry = batch.work.get_mut(name.as_str()).unwrap(); if entry.refresh(&batch.recipe, None).is_err() { errors += 1; } } } match silver_platter::batch::save_batch_metadata(&directory, &batch) { Ok(_) => {} Err(e) => { error!( "Failed to save batch metadata to {}: {}", directory.display(), e ); return Err(1); } } if batch.work.is_empty() { info!( "No work left in batch.yaml; you can now remove {}", directory.display() ); } if errors > 0 { Err(1) } else { Ok(()) } } pub fn batch_publish( directory: &Path, codebase: Option<&str>, refresh: bool, overwrite: bool, ) -> Result<(), i32> { let directory = directory.canonicalize().unwrap(); let mut batch = match silver_platter::batch::load_batch_metadata(&directory) { Ok(Some(batch)) => batch, Ok(None) => { info!("No batch.yaml found in {}", directory.display()); return Err(1); } Err(e) => { error!( "Failed to load batch metadata from {}: {}", directory.display(), e ); return Err(1); } }; let mut errors = 0; if let Some(codebase) = codebase { if publish_entry(&mut batch, codebase, refresh, overwrite) { silver_platter::batch::save_batch_metadata(&directory, &batch).unwrap(); } else { error!("Failed to publish {}", codebase); errors = 1; } } else { let names = batch.work.keys().cloned().collect::>(); for name in names { if !publish_entry(&mut batch, name.as_str(), refresh, overwrite) { errors += 1; } } silver_platter::batch::save_batch_metadata(&directory, &batch).unwrap(); } if batch.work.is_empty() { info!( "No work left in batch.yaml; you can now remove {}", directory.display() ); } if errors > 0 { Err(1) } else { Ok(()) } } fn login(url: &url::Url) -> i32 { let lp_uris = breezyshim::launchpad::uris().unwrap(); let forge = if url.host_str() == Some("github.com") { "github" } else if lp_uris.iter().any(|(_key, root)| { url.host_str() == Some(root) || url.host_str() == Some(root.trim_end_matches('/')) }) { "launchpad" } else { "gitlab" }; match forge { "gitlab" => { breezyshim::gitlab::login(url).unwrap(); } "github" => { breezyshim::github::login().unwrap(); } "launchpad" => { breezyshim::launchpad::login(url); } _ => { panic!("Unknown forge {}", forge); } } 0 } fn main() -> Result<(), i32> { let cli = Cli::parse(); env_logger::builder() .format(|buf, record| writeln!(buf, "{}", record.args())) .filter( None, if cli.debug { log::LevelFilter::Debug } else { log::LevelFilter::Info }, ) .init(); breezyshim::init(); breezyshim::plugin::load_plugins(); match &cli.command { Commands::Forges {} => { for instance in breezyshim::forge::iter_forge_instances() { println!("{} ({})", instance.base_url(), instance.forge_kind()); } Ok(()) } Commands::Login { url } => match login(url) { 0 => Ok(()), e => Err(e), }, Commands::Proposals { status } => { let statuses = status.as_ref().map(|status| vec![*status]); for (_forge, proposal) in silver_platter::proposal::iter_all_mps(statuses) { println!("{}", proposal.url().unwrap()); } Ok(()) } Commands::Run(args) => match run(args) { 0 => Ok(()), e => Err(e), }, Commands::Apply { command, diff, commit_pending, verify_command, recipe, } => { let recipe = recipe .as_ref() .map(|recipe| silver_platter::recipe::Recipe::from_path(recipe).unwrap()); let commit_pending = if let Some(commit_pending) = commit_pending { *commit_pending } else if let Some(recipe) = &recipe { recipe.commit_pending } else { silver_platter::CommitPending::Auto }; let command = if let Some(command) = command.as_ref() { shlex::split(command.as_str()).unwrap() } else if let Some(recipe) = &recipe { recipe.command.clone().unwrap().argv() } else { error!("No command specified"); return Err(1); }; let (local_tree, subpath) = workingtree::open_containing(Path::new(".")).unwrap(); check_clean_tree( &local_tree, &local_tree.basis_tree().unwrap(), subpath.as_path(), ) .unwrap(); let result = match script_runner( &local_tree, command .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), subpath.as_path(), commit_pending, None, None, None, std::process::Stdio::inherit(), ) { Ok(result) => result, Err(err) => { error!("Failed: {}", err); reset_tree(&local_tree, None, Some(subpath.as_path())).unwrap(); return Err(1); } }; if let Some(description) = result.description { info!("Succeeded: {} ", description); } if let Some(verify_command) = verify_command { match std::process::Command::new(verify_command) .current_dir(local_tree.abspath(subpath.as_path()).unwrap()) .status() { Ok(status) if status.success() => {} Ok(status) => { error!("Verify command failed: {}", status); reset_tree(&local_tree, None, Some(subpath.as_path())).unwrap(); return Err(1); } Err(err) => { error!("Verify command failed: {}", err); reset_tree(&local_tree, None, Some(subpath.as_path())).unwrap(); return Err(1); } } } if *diff { let old_tree = local_tree.revision_tree(&result.old_revision).unwrap(); let new_tree = local_tree.revision_tree(&result.new_revision).unwrap(); breezyshim::diff::show_diff_trees( old_tree.as_ref(), new_tree.as_ref(), Box::new(std::io::stdout()), Some("old/"), Some("new/"), ) .unwrap(); } Ok(()) } Commands::Batch(args) => match args { BatchArgs::Generate { recipe, candidates, directory, } => { let mut extra_env = std::collections::HashMap::new(); let recipe = if let Some(recipe) = recipe { extra_env.insert( "RECIPEDIR".to_string(), recipe .as_path() .parent() .unwrap() .to_str() .unwrap() .to_string(), ); silver_platter::recipe::Recipe::from_path(recipe.as_path()).unwrap() } else { panic!("No recipe specified"); }; let candidates = if let Some(candidate_list) = candidates { Candidates::from_path(candidate_list.as_path()).unwrap() } else { Candidates::new() }; let directory = if let Some(directory) = directory.as_ref() { directory.clone() } else { info!("Using output directory: {}", recipe.name.as_ref().unwrap()); std::path::PathBuf::from(recipe.name.clone().unwrap()) }; match silver_platter::batch::Batch::from_recipe( &recipe, candidates.iter(), directory.as_path(), Some(extra_env), ) { Ok(_batch) => {} Err(e) => { error!("Failed to generate batch: {}", e); return Err(1); } } info!("Now, review the patches under {}, edit {} as appropriate and then run \"svp batch publish {}\"", directory.display(), directory.join("batch.yaml").display(), directory.display()); info!( "You can run \"svp batch status {}\" to see the status of the patches", directory.display() ); info!( "To refresh the patches, run \"svp batch refresh {}\"", directory.display() ); Ok(()) } BatchArgs::Publish { directory, name, overwrite, refresh, } => { let ret = batch_publish(directory.as_path(), name.as_deref(), *refresh, *overwrite); info!( "To see the status of open merge requests, run: \"svp batch status {}\"", directory.display() ); ret } BatchArgs::Status { directory, codebase, } => { let directory = directory.canonicalize().unwrap(); let batch = match silver_platter::batch::load_batch_metadata(&directory) { Ok(Some(batch)) => batch, Ok(None) => { info!("No batch.yaml found in {}", directory.display()); return Err(1); } Err(e) => { error!( "Failed to load batch metadata from {}: {}", directory.display(), e ); return Err(1); } }; if let Some(codebase) = codebase { let entry = batch.work.get(codebase).unwrap(); info!("{}: {}", codebase, entry.status()); } else { for (name, entry) in batch.work.iter() { info!("{}: {}", name, entry.status()); } } Ok(()) } BatchArgs::Diff { directory, codebase, } => { let directory = directory.canonicalize().unwrap(); let batch = match silver_platter::batch::load_batch_metadata(&directory) { Ok(Some(batch)) => batch, Ok(None) => { info!("No batch.yaml found in {}", directory.display()); return Err(1); } Err(e) => { error!( "Failed to load batch metadata from {}: {}", directory.display(), e ); return Err(1); } }; let entry = batch.work.get(codebase.as_str()).unwrap(); let main_branch = match entry.target_branch() { Ok(branch) => branch, Err(e) => { error!("Failed to open branch: {}", e); return Err(1); } }; let local_branch = match entry.local_branch() { Ok(branch) => branch, Err(e) => { error!("Failed to open branch: {}", e); return Err(1); } }; let repository = local_branch.repository(); let main_revision = main_branch.last_revision(); repository .fetch(&main_branch.repository(), Some(&main_revision)) .unwrap(); let main_tree = repository.revision_tree(&main_revision).unwrap(); breezyshim::diff::show_diff_trees( &main_tree, &local_branch.basis_tree().unwrap(), Box::new(std::io::stdout()), Some("old/"), Some("new/"), ) .unwrap(); Err(1) } BatchArgs::Refresh { directory, codebase, } => batch_refresh(directory.as_path(), codebase.as_deref()), }, } } silver-platter-0.5.44/src/debian/codemod.rs0000644000000000000000000003120414721061524015502 0ustar00//! Codemod runner use crate::debian::{add_changelog_entry, control_files_in_root, guess_update_changelog}; use crate::CommitPending; use breezyshim::error::Error as BrzError; use breezyshim::tree::{MutableTree, Tree, WorkingTree}; use breezyshim::RevisionId; use debian_changelog::get_maintainer_from_env; use debian_changelog::ChangeLog; use std::collections::HashMap; use url::Url; #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] /// A codemod command result pub struct CommandResult { /// Source package name pub source_name: String, /// Result value pub value: Option, /// Unique representation of the context pub context: Option, /// Description of the command pub description: String, /// Serialized context pub serialized_context: Option, /// Tags pub tags: Vec<(String, Option)>, /// Target branch URL pub target_branch_url: Option, /// Old revision pub old_revision: RevisionId, /// New revision pub new_revision: RevisionId, } impl crate::CodemodResult for CommandResult { fn context(&self) -> serde_json::Value { self.context.clone().unwrap_or_default() } fn value(&self) -> Option { self.value } fn target_branch_url(&self) -> Option { self.target_branch_url.clone() } fn description(&self) -> Option { Some(self.description.clone()) } fn tags(&self) -> Vec<(String, Option)> { self.tags.clone() } } impl From<&CommandResult> for DetailedSuccess { fn from(r: &CommandResult) -> Self { DetailedSuccess { value: r.value, context: r.context.clone(), description: Some(r.description.clone()), serialized_context: r.serialized_context.clone(), tags: Some( r.tags .iter() .map(|(k, v)| (k.clone(), v.as_ref().map(|v| v.to_string()))) .collect(), ), target_branch_url: r.target_branch_url.clone(), } } } #[derive(Debug, serde::Deserialize, serde::Serialize, Default)] struct DetailedSuccess { value: Option, context: Option, description: Option, serialized_context: Option, tags: Option)>>, #[serde(rename = "target-branch-url")] target_branch_url: Option, } #[derive(Debug)] /// Code mod error pub enum Error { /// The script made no changes ScriptMadeNoChanges, /// The script was not found ScriptNotFound, /// No changelog found MissingChangelog(std::path::PathBuf), /// Changelog parse error ChangelogParse(debian_changelog::ParseError), /// Script exited with a non-zero exit code ExitCode(i32), /// Detailed failure Detailed(DetailedFailure), /// I/O error Io(std::io::Error), /// JSON error Json(serde_json::Error), /// UTF-8 error Utf8(std::string::FromUtf8Error), /// Other error Other(String), } impl From for Error { fn from(e: debian_changelog::Error) -> Self { match e { debian_changelog::Error::Io(e) => Error::Io(e), debian_changelog::Error::Parse(e) => Error::ChangelogParse(e), } } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Error::ScriptMadeNoChanges => write!(f, "Script made no changes"), Error::ScriptNotFound => write!(f, "Script not found"), Error::ExitCode(code) => write!(f, "Script exited with code {}", code), Error::Detailed(d) => write!(f, "Script failed: {:?}", d), Error::Io(e) => write!(f, "Command failed: {}", e), Error::Json(e) => write!(f, "JSON error: {}", e), Error::Utf8(e) => write!(f, "UTF-8 error: {}", e), Error::Other(s) => write!(f, "{}", s), Error::ChangelogParse(e) => write!(f, "Changelog parse error: {}", e), Error::MissingChangelog(p) => write!(f, "Missing changelog at {}", p.display()), } } } impl From for Error { fn from(e: serde_json::Error) -> Self { Error::Json(e) } } impl From for Error { fn from(e: std::io::Error) -> Self { Error::Io(e) } } impl From for Error { fn from(e: std::string::FromUtf8Error) -> Self { Error::Utf8(e) } } impl std::error::Error for Error {} #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] /// A detailed failure pub struct DetailedFailure { /// Result code pub result_code: String, /// Description of the failure pub description: Option, /// Stage at with the failure occurred pub stage: Option>, /// Details about the failure pub details: Option, } /// Run a script in a tree and commit the result. /// /// This ignores newly added files. /// /// # Arguments /// /// - `local_tree`: Local tree to run script in /// - `subpath`: Subpath to run script in /// - `script`: Script to run /// - `commit_pending`: Whether to commit pending changes pub fn script_runner( local_tree: &WorkingTree, script: &[&str], subpath: &std::path::Path, commit_pending: CommitPending, resume_metadata: Option<&serde_json::Value>, committer: Option<&str>, extra_env: Option>, stderr: std::process::Stdio, update_changelog: Option, ) -> Result { let mut env = std::env::vars().collect::>(); if let Some(extra_env) = extra_env.as_ref() { for (k, v) in extra_env { env.insert(k.to_string(), v.to_string()); } } env.insert("SVP_API".to_string(), "1".to_string()); let debian_path = if control_files_in_root(local_tree, subpath) { subpath.to_owned() } else { subpath.join("debian") }; let update_changelog = update_changelog.unwrap_or_else(|| { if let Some(dch_guess) = guess_update_changelog(local_tree, &debian_path) { log::info!("{}", dch_guess.explanation); dch_guess.update_changelog } else { // Assume yes. true } }); let cl_path = debian_path.join("changelog"); let source_name = match local_tree.get_file_text(&cl_path) { Ok(text) => debian_changelog::ChangeLog::read(text.as_slice()) .unwrap() .iter() .next() .and_then(|e| e.package()), Err(BrzError::NoSuchFile(_)) => None, Err(e) => { return Err(Error::Other(format!("Failed to read changelog: {}", e))); } }; let last_revision = local_tree.last_revision().unwrap(); let mut orig_tags = local_tree.get_tag_dict().unwrap(); let td = tempfile::tempdir()?; let result_path = td.path().join("result.json"); env.insert( "SVP_RESULT".to_string(), result_path.to_string_lossy().to_string(), ); if let Some(resume_metadata) = resume_metadata { let resume_path = td.path().join("resume.json"); env.insert( "SVP_RESUME".to_string(), resume_path.to_string_lossy().to_string(), ); let w = std::fs::File::create(&resume_path)?; serde_json::to_writer(w, &resume_metadata)?; } let mut command = std::process::Command::new(script[0]); command.args(&script[1..]); command.envs(env); command.stdout(std::process::Stdio::piped()); command.stderr(stderr); command.current_dir(local_tree.abspath(subpath).unwrap()); let ret = match command.output() { Ok(ret) => ret, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Err(Error::ScriptNotFound); } Err(e) => { return Err(Error::Io(e)); } }; if !ret.status.success() { return Err(match std::fs::read_to_string(&result_path) { Ok(result) => { let result: DetailedFailure = serde_json::from_str(&result)?; Error::Detailed(result) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Error::ExitCode(ret.status.code().unwrap_or(1)) } Err(_) => Error::ExitCode(ret.status.code().unwrap_or(1)), }); } // If the changelog didn't exist earlier, then hopefully it was created // now. let source_name: String = if let Some(source_name) = source_name { source_name } else { match local_tree.get_file_text(&cl_path) { Ok(text) => match ChangeLog::read(text.as_slice())? .iter() .next() .and_then(|e| e.package()) { Some(source_name) => source_name, None => { return Err(Error::Other(format!( "Failed to read changelog: {}", cl_path.display() ))); } }, Err(BrzError::NoSuchFile(_)) => { return Err(Error::MissingChangelog(cl_path)); } Err(e) => { return Err(Error::Other(format!("Failed to read changelog: {}", e))); } } }; // Open result_path, read metadata let mut result: DetailedSuccess = match std::fs::read_to_string(&result_path) { Ok(result) => serde_json::from_str(&result)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => DetailedSuccess::default(), Err(e) => return Err(e.into()), }; if result.description.is_none() { result.description = Some(String::from_utf8(ret.stdout)?); } let mut new_revision = local_tree.last_revision().unwrap(); let tags: Vec<(String, Option)> = if let Some(tags) = result.tags { tags.into_iter() .map(|(n, v)| (n, v.map(|v| RevisionId::from(v.as_bytes().to_vec())))) .collect() } else { let mut tags = local_tree .get_tag_dict() .unwrap() .into_iter() .filter_map(|(n, v)| { if orig_tags.remove(n.as_str()).as_ref() != Some(&v) { Some((n, Some(v))) } else { None } }) .collect::>(); tags.extend(orig_tags.into_keys().map(|n| (n, None))); tags }; let commit_pending = match commit_pending { CommitPending::Yes => true, CommitPending::No => false, CommitPending::Auto => { // Automatically commit pending changes if the script did not // touch the branch last_revision == new_revision } }; if commit_pending { if update_changelog && result.description.is_some() && local_tree.has_changes().unwrap() { let maintainer = match extra_env.map(|e| get_maintainer_from_env(|k| e.get(k).cloned())) { Some(Some((name, email))) => Some((name, email)), _ => None, }; add_changelog_entry( local_tree, &debian_path.join("changelog"), vec![result.description.as_ref().unwrap().as_str()].as_slice(), maintainer.as_ref(), None, None, ); } local_tree .smart_add(&[local_tree.abspath(subpath).unwrap().as_path()]) .unwrap(); let mut builder = local_tree .build_commit() .message(result.description.as_ref().unwrap()) .allow_pointless(false); if let Some(committer) = committer { builder = builder.committer(committer); } new_revision = match builder.commit() { Ok(rev) => rev, Err(BrzError::PointlessCommit) => { // No changes last_revision.clone() } Err(e) => return Err(Error::Other(format!("Failed to commit changes: {}", e))), }; } if new_revision == last_revision { return Err(Error::ScriptMadeNoChanges); } let old_revision = last_revision; let new_revision = local_tree.last_revision().unwrap(); Ok(CommandResult { source_name, old_revision, new_revision, tags, description: result.description.unwrap(), value: result.value, context: result.context, serialized_context: result.serialized_context, target_branch_url: result.target_branch_url, }) } silver-platter-0.5.44/src/debian/mod.rs0000644000000000000000000006005014721061524014650 0ustar00//! Utility functions for working with Debian packages. use breezyshim::branch::Branch; use breezyshim::debian::apt::Apt; use breezyshim::tree::{MutableTree, Tree, WorkingTree}; use debian_changelog::{ChangeLog, Urgency}; use std::collections::HashMap; use pyo3::prelude::*; use pyo3::types::PyDict; use std::path::Path; /// Default build command pub const DEFAULT_BUILDER: &str = "sbuild --no-clean-source"; pub mod codemod; pub mod run; pub mod uploader; /// Check whether the control files are in the root of the package. pub fn control_files_in_root(tree: &dyn Tree, subpath: &Path) -> bool { let debian_path = subpath.join("debian"); if tree.has_filename(&debian_path) { return false; } let control_path = subpath.join("control"); if tree.has_filename(&control_path) { return true; } let template_control_path = control_path.with_extension("in"); tree.has_filename(&template_control_path) } #[cfg(not(feature = "detect-update-changelog"))] #[derive(Debug, Clone, PartialEq, Eq)] /// Changelog behaviour pub struct ChangelogBehaviour { /// Whether to update the changelog pub update_changelog: bool, /// Explanation for the decision pub explanation: String, } #[cfg(feature = "detect-update-changelog")] pub use debian_analyzer::detect_gbp_dch::ChangelogBehaviour; #[cfg(not(feature = "detect-update-changelog"))] impl FromPyObject<'_> for ChangelogBehaviour { fn extract_bound(obj: &Bound) -> PyResult { let update_changelog = obj.getattr("update_changelog")?.extract()?; let explanation = obj.getattr("explanation")?.extract()?; Ok(ChangelogBehaviour { update_changelog, explanation, }) } } /// Guess whether the changelog should be updated. pub fn guess_update_changelog( #[allow(unused_variables)] tree: &WorkingTree, #[allow(unused_variables)] debian_path: &Path, ) -> Option { #[cfg(feature = "detect-update-changelog")] { debian_analyzer::detect_gbp_dch::guess_update_changelog(tree, debian_path, None) } #[cfg(not(feature = "detect-update-changelog"))] { log::warn!("Install lintian-brush to detect automatically whether the changelog should be updated."); return Some(ChangelogBehaviour { update_changelog: true, explanation: format!( "defaulting to updating changelog since silver-platter was built without lintian-brush" ), }); } } /// Add a changelog entry. /// /// # Arguments /// * `tree` - Tree to edit /// * `path` - Path to the changelog file /// * `summary` - Entry to add /// * `maintainer` - Maintainer details; tuple of fullname and email pub fn add_changelog_entry( tree: &dyn MutableTree, path: &Path, summary: &[&str], maintainer: Option<&(String, String)>, timestamp: Option>, urgency: Option, ) { let maintainer = if let Some(maintainer) = maintainer { Some(maintainer.clone()) } else { debian_changelog::get_maintainer() }; // TODO(jelmer): This logic should ideally be in python-debian. let f = tree.get_file(path).unwrap(); let mut cl = ChangeLog::read_relaxed(f).unwrap(); let summary = vec![format!("* {}", summary[0])] .into_iter() .chain(summary[1..].iter().map(|l| format!(" {}", l))) .collect::>(); cl.auto_add_change( summary .iter() .map(|l| l.as_str()) .collect::>() .as_slice(), maintainer.unwrap(), timestamp, urgency, ); tree.put_file_bytes_non_atomic(path, cl.to_string().as_bytes()) .unwrap(); } /// Check if a directory is a debcargo package. pub fn is_debcargo_package(tree: &dyn Tree, subpath: &Path) -> bool { let control_path = subpath.join("debian").join("debcargo.toml"); tree.has_filename(&control_path) } /// Install the built package. pub fn install_built_package( local_tree: &WorkingTree, subpath: &Path, build_target_dir: &Path, ) -> Result<(), Box> { let abspath = local_tree .abspath(subpath) .unwrap() .join("debian/changelog"); let cl = ChangeLog::read_path(abspath)?; let first_entry = cl.iter().next().unwrap(); let package = first_entry.package().unwrap(); let version = first_entry.version().unwrap(); let mut non_epoch_version = version.upstream_version.clone(); if let Some(debian_version) = &version.debian_revision { non_epoch_version.push_str(&format!("-{}", debian_version)); } let re_pattern = format!( "{}_{}_.*\\.changes", regex::escape(&package), regex::escape(&non_epoch_version) ); let c = regex::Regex::new(&re_pattern)?; for entry in std::fs::read_dir(build_target_dir)? { let entry = entry?; let file_name = entry.file_name().into_string().unwrap_or_default(); if !c.is_match(&file_name) { continue; } let path = entry.path(); let contents = std::fs::read(&path)?; let binary: Option = Python::with_gil(|py| { let m = py.import_bound("debian.deb822")?; let changes = m.getattr("Changes")?.call1((contents,))?; changes.call_method1("get", ("Binary",))?.extract() })?; if binary.is_some() { std::process::Command::new("debi") .arg(entry.path()) .status()?; } } Ok(()) } /// Build a debian package in a directory. /// /// # Arguments /// * `tree` - Working tree /// * `subpath` - Subpath to build in /// * `builder` - Builder command (e.g. 'sbuild', 'debuild') /// * `result_dir` - Directory to copy results to pub fn build( tree: &WorkingTree, subpath: &Path, builder: Option<&str>, result_dir: Option<&Path>, ) -> PyResult<()> { let builder = builder.unwrap_or(DEFAULT_BUILDER); let path = tree.abspath(subpath).unwrap(); // TODO(jelmer): Refactor brz-debian so it's not necessary // to call out to cmd_builddeb, but to lower-level // functions instead. Python::with_gil(|py| { let m = py.import_bound("breezy.plugins.debian.cmds")?; let cmd_builddeb = m.getattr("cmd_builddeb")?; let kwargs = PyDict::new_bound(py); kwargs.set_item("builder", builder)?; kwargs.set_item("result_dir", result_dir)?; cmd_builddeb.call((path,), Some(&kwargs))?; Ok(()) }) } /// Run `gbp dch` in a directory. pub fn gbp_dch(path: &std::path::Path) -> Result<(), std::io::Error> { let mut cmd = std::process::Command::new("gbp"); cmd.arg("dch").arg("--ignore-branch"); cmd.current_dir(path); let status = cmd.status()?; if !status.success() { return Err(std::io::Error::new( std::io::ErrorKind::Other, format!("gbp dch failed: {}", status), )); } Ok(()) } /// Find the last release revision id for a given version. pub fn find_last_release_revid( branch: &dyn breezyshim::branch::Branch, version: debversion::Version, ) -> PyResult { Python::with_gil(|py| { let m = py.import_bound("breezy.plugins.debian.import_dsc")?; let db = m .getattr("DistributionBranch")? .call1((branch.to_object(py), py.None()))?; db.call_method1("revid_of_version", (version,))?.extract() }) } /// Pick the additional colocated branches to use for a given main branch. pub fn pick_additional_colocated_branches(main_branch: &dyn Branch) -> HashMap { let mut ret: HashMap = vec![ ("pristine-tar", "pristine-tar"), ("pristine-lfs", "pristine-lfs"), ("upstream", "upstream"), ] .into_iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); if let Some(name) = main_branch.name() { ret.insert(format!("patch-queue/{}", name), "patch-queue".to_string()); if name.starts_with("debian/") { let mut parts = name.split('/').collect::>(); parts[0] = "upstream"; ret.insert(parts.join("/"), "upstream".to_string()); } } let existing_branch_names = main_branch.controldir().branch_names().unwrap(); ret.into_iter() .filter(|(k, _)| existing_branch_names.contains(k)) .collect() } /// Get source package metadata. /// /// # Arguments /// * `apt_repo` - A `Apt` object /// * `name` - Name of the source package pub fn apt_get_source_package( apt_repo: &dyn Apt, name: &str, ) -> Option { let mut by_version = HashMap::new(); for source in apt_repo.iter_source_by_name(name) { by_version.insert(source.version().unwrap(), source); } if by_version.is_empty() { return None; } // Try the latest version let latest_version = by_version.keys().max().unwrap().clone(); by_version.remove(&latest_version) } /// Open a packaging branch from a location string. /// /// location can either be a package name or a full URL pub fn open_packaging_branch( location: &str, possible_transports: Option<&mut Vec>, vcs_type: Option<&str>, apt_repo: Option<&dyn Apt>, ) -> Result<(Box, std::path::PathBuf), crate::vcs::BranchOpenError> { let mut vcs_type = vcs_type.map(|s| s.to_string()); let (url, branch_name, subpath) = if !location.contains('/') && !location.contains(':') { let pkg_source = if apt_repo.is_none() { apt_get_source_package( &breezyshim::debian::apt::LocalApt::new(None).unwrap(), location, ) } else { apt_get_source_package(apt_repo.unwrap(), location) }; let pkg_source = match pkg_source { Some(pkg_source) => pkg_source, None => { return Err(crate::vcs::BranchOpenError::Missing { url: location.parse().unwrap(), description: format!("Package {} not found in apt", location), }) } }; match debian_analyzer::vcs::vcs_field(&pkg_source) { Some((new_vcs_type, vcs_url)) => { vcs_type = Some(new_vcs_type); let parsed_vcs: debian_control::vcs::ParsedVcs = vcs_url.parse().unwrap(); ( parsed_vcs.repo_url.parse().unwrap(), parsed_vcs.branch, Some(std::path::PathBuf::from("")), ) } None => { return Err(crate::vcs::BranchOpenError::Missing { url: location.parse().unwrap(), description: format!("Package {} does not have VCS information", location), }) } } } else { let (url, params) = breezyshim::urlutils::split_segment_parameters(&location.parse().unwrap()); let branch_name = params.get("branch").map(|b| { percent_encoding::percent_decode_str(b) .decode_utf8() .unwrap() .into_owned() }); (url, branch_name, None) }; let probers = crate::probers::select_probers(vcs_type.as_deref()); let branch = crate::vcs::open_branch( &url, possible_transports, Some( probers .iter() .map(|p| p.as_ref()) .collect::>() .as_slice(), ), branch_name.as_deref(), )?; Ok(( branch, subpath.unwrap_or_else(|| std::path::PathBuf::from("")), )) } #[cfg(test)] mod tests { use super::*; use breezyshim::controldir::{create_branch_convenience, ControlDirFormat}; use breezyshim::tree::WorkingTree; use std::path::Path; pub fn make_branch_and_tree(path: &std::path::Path) -> WorkingTree { breezyshim::init(); let path = path.canonicalize().unwrap(); let url = url::Url::from_file_path(path).unwrap(); let branch = create_branch_convenience(&url, None, &ControlDirFormat::default()).unwrap(); branch.controldir().open_workingtree().unwrap() } #[test] fn test_edit_existing_new_author() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"lintian-brush (0.35) UNRELEASED; urgency=medium * Initial change. * Support updating templated debian/control files that use cdbs template. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "# .as_bytes(), ) .unwrap(); tree.add(&[(Path::new("debian")), (Path::new("debian/changelog"))]) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &["Add a foo"], Some(&("Jane Example".to_string(), "jane@example.com".to_string())), None, None, ); assert_eq!( r#"lintian-brush (0.35) UNRELEASED; urgency=medium [ Joe Example ] * Initial change. * Support updating templated debian/control files that use cdbs template. [ Jane Example ] * Add a foo -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap(), ); } #[test] fn test_edit_existing_multi_new_author() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"lintian-brush (0.35) UNRELEASED; urgency=medium [ Jane Example ] * Support updating templated debian/control files that use cdbs template. [ Joe Example ] * Another change -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, ) .unwrap(); tree.add(&[(Path::new("debian")), (Path::new("debian/changelog"))]) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &["Add a foo"], Some(&("Jane Example".to_string(), "jane@example.com".to_string())), None, None, ); assert_eq!( r#"lintian-brush (0.35) UNRELEASED; urgency=medium [ Jane Example ] * Support updating templated debian/control files that use cdbs template. [ Joe Example ] * Another change [ Jane Example ] * Add a foo -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap() ); } #[test] fn test_edit_existing_existing_author() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"lintian-brush (0.35) UNRELEASED; urgency=medium * Support updating templated debian/control files that use cdbs template. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, ) .unwrap(); tree.add(&[(Path::new("debian")), (Path::new("debian/changelog"))]) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &["Add a foo"], Some(&("Joe Example".to_string(), "joe@example.com".to_string())), None, None, ); assert_eq!( r#"lintian-brush (0.35) UNRELEASED; urgency=medium * Support updating templated debian/control files that use cdbs template. * Add a foo -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap() ); } #[test] fn test_add_new() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"lintian-brush (0.35) unstable; urgency=medium * Support updating templated debian/control files that use cdbs template. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, ) .unwrap(); tree.add(&[(Path::new("debian")), (Path::new("debian/changelog"))]) .unwrap(); std::env::set_var("DEBCHANGE_VENDOR", "debian"); let timestamp = chrono::DateTime::::parse_from_rfc3339( "2020-05-24T15:27:26+00:00", ) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &["Add a foo"], Some(&( String::from("Jane Example"), String::from("jane@example.com"), )), Some(timestamp), None, ); assert_eq!( r#"lintian-brush (0.36) UNRELEASED; urgency=low * Add a foo -- Jane Example Sun, 24 May 2020 15:27:26 +0000 lintian-brush (0.35) unstable; urgency=medium * Support updating templated debian/control files that use cdbs template. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap() ); } #[test] fn test_edit_broken_first_line() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"THIS IS NOT A PARSEABLE LINE lintian-brush (0.35) UNRELEASED; urgency=medium * Support updating templated debian/control files that use cdbs template. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, ) .unwrap(); tree.add(&[Path::new("debian"), Path::new("debian/changelog")]) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &["Add a foo", "+ Bar"], Some(&("Jane Example".to_string(), "joe@example.com".to_string())), None, None, ); assert_eq!( r#"THIS IS NOT A PARSEABLE LINE lintian-brush (0.35) UNRELEASED; urgency=medium [ Joe Example ] * Support updating templated debian/control files that use cdbs template. [ Jane Example ] * Add a foo + Bar -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap() ); } #[test] fn test_add_long_line() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"lintian-brush (0.35) UNRELEASED; urgency=medium * Support updating templated debian/control files that use cdbs template. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "# .as_bytes(), ) .unwrap(); tree.add(&[Path::new("debian"), Path::new("debian/changelog")]) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &[ "This is adding a very long sentence that is longer than would fit on a single line in a 80-character-wide line." ], Some(&("Joe Example".to_string(), "joe@example.com".to_string())), None, None, ); assert_eq!( r#"lintian-brush (0.35) UNRELEASED; urgency=medium * Support updating templated debian/control files that use cdbs template. * This is adding a very long sentence that is longer than would fit on a single line in a 80-character-wide line. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap() ); } #[test] fn test_add_long_subline() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"lintian-brush (0.35) UNRELEASED; urgency=medium * Support updating templated debian/control files that use cdbs template. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "# .as_bytes(), ) .unwrap(); tree.add(&[Path::new("debian"), Path::new("debian/changelog")]) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &[ "This is the main item.", "+ This is adding a very long sentence that is longer than would fit on a single line in a 80-character-wide line.", ], Some(&("Joe Example".to_string(), "joe@example.com".to_string())), None, None ); assert_eq!( r#"lintian-brush (0.35) UNRELEASED; urgency=medium * Support updating templated debian/control files that use cdbs template. * This is the main item. + This is adding a very long sentence that is longer than would fit on a single line in a 80-character-wide line. -- Joe Example Fri, 04 Oct 2019 02:36:13 +0000 "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap() ); } #[test] fn test_trailer_only() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"lintian-brush (0.35) unstable; urgency=medium * This line already existed. -- "# .as_bytes(), ) .unwrap(); tree.add(&[Path::new("debian"), Path::new("debian/changelog")]) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &["And this one is new."], Some(&("Jane Example".to_string(), "joe@example.com".to_string())), None, None, ); assert_eq!( r#"lintian-brush (0.35) unstable; urgency=medium * This line already existed. * And this one is new. -- "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap() ); } #[test] fn test_trailer_only_existing_author() { let td = tempfile::tempdir().unwrap(); let tree = make_branch_and_tree(td.path()); std::fs::create_dir_all(td.path().join("debian")).unwrap(); std::fs::write( td.path().join("debian/changelog"), r#"lintian-brush (0.35) unstable; urgency=medium * This line already existed. [ Jane Example ] * And this one has an existing author. -- "# .as_bytes(), ) .unwrap(); tree.add(&[Path::new("debian"), Path::new("debian/changelog")]) .unwrap(); add_changelog_entry( &tree, Path::new("debian/changelog"), &["And this one is new."], Some(&("Joe Example".to_string(), "joe@example.com".to_string())), None, None, ); assert_eq!( r#"lintian-brush (0.35) unstable; urgency=medium * This line already existed. [ Jane Example ] * And this one has an existing author. [ Joe Example ] * And this one is new. -- "#, std::fs::read_to_string(td.path().join("debian/changelog")).unwrap() ); } } silver-platter-0.5.44/src/debian/run.rs0000644000000000000000000002230214721061524014673 0ustar00//! Run the given command and publish the changes as a merge proposal. use crate::debian::codemod::{CommandResult, Error as CommandError}; use crate::publish::{ enable_tag_pushing, find_existing_proposed, DescriptionFormat, Error as PublishError, }; use crate::vcs::{open_branch, BranchOpenError}; use crate::workspace::Workspace; use crate::Mode; use breezyshim::branch::Branch; use breezyshim::error::Error as BrzError; use breezyshim::forge::{get_forge, Forge, MergeProposal}; use log::{error, info, warn}; use std::collections::HashMap; use url::Url; /// Run the given command and publish the changes as a merge proposal. pub fn apply_and_publish( url: &Url, name: &str, command: &[&str], mode: Mode, commit_pending: crate::CommitPending, labels: Option<&[&str]>, diff: bool, derived_owner: Option<&str>, refresh: bool, allow_create_proposal: Option bool>, mut get_commit_message: Option< impl FnOnce(&CommandResult, Option<&MergeProposal>) -> Option, >, get_title: Option) -> Option>, get_description: impl FnOnce(&CommandResult, DescriptionFormat, Option<&MergeProposal>) -> String, update_changelog: Option, build_verify: bool, mut build_target_dir: Option, builder: Option, install: bool, extra_env: Option>, ) -> i32 { let main_branch = match open_branch(url, None, None, None) { Err(BranchOpenError::Unavailable { url, description, .. }) | Err(BranchOpenError::Missing { url, description, .. }) | Err(BranchOpenError::RateLimited { url, description, .. }) | Err(BranchOpenError::TemporarilyUnavailable { url, description, .. }) | Err(BranchOpenError::Unsupported { url, description, .. }) => { error!("{}: {}", url, description); return 2; } Err(BranchOpenError::Other(e)) => { error!("{}: {}", url, e); return 2; } Ok(b) => b, }; let mut overwrite = false; let (forge, existing_proposals, mut resume_branch): ( Option, Vec, Option>, ) = match get_forge(main_branch.as_ref()) { Err(BrzError::UnsupportedForge(e)) => { if mode != Mode::Push { error!("{}: {}", url, e); return 2; } // We can't figure out what branch to resume from when there's no forge // that can tell us. warn!( "Unsupported forge ({}), will attempt to push to {}", e, crate::vcs::full_branch_url(main_branch.as_ref()), ); (None, vec![], None) } Err(BrzError::ForgeProjectExists(_)) | Err(BrzError::AlreadyControlDir(..)) => { unreachable!() } Err(BrzError::ForgeLoginRequired) => { warn!("Login required to access forge"); return 2; } Err(e) => { error!("Failed to get forge: {}", e); return 2; } Ok(ref forge) => { let (resume_branch, resume_overwrite, existing_proposals) = find_existing_proposed( main_branch.as_ref(), forge, name, false, derived_owner, None, ) .unwrap(); if let Some(resume_overwrite) = resume_overwrite { overwrite = resume_overwrite; } ( Some(forge.clone()), existing_proposals.unwrap_or_default(), resume_branch, ) } }; if refresh { if resume_branch.is_some() { overwrite = true; } resume_branch = None; } let existing_proposal = if existing_proposals.len() > 1 { warn!( "Multiple open merge proposals for branch at {}: {:?}", resume_branch.as_ref().unwrap().get_user_url(), existing_proposals .iter() .map(|mp| mp.url().unwrap()) .collect::>() ); let existing_proposal = existing_proposals.into_iter().next().unwrap(); info!("Updating {}", existing_proposal.url().unwrap()); Some(existing_proposal) } else { None }; let subpath = std::path::Path::new(""); let mut ws_builder = Workspace::builder(); ws_builder = ws_builder.additional_colocated_branches( crate::debian::pick_additional_colocated_branches(main_branch.as_ref()), ); ws_builder = ws_builder.main_branch(main_branch); ws_builder = if let Some(resume_branch) = resume_branch.take() { ws_builder.resume_branch(resume_branch) } else { ws_builder }; let ws = match ws_builder.build() { Ok(ws) => ws, Err(e) => { error!("Failed to start workspace: {}", e); return 2; } }; let result: CommandResult = match crate::debian::codemod::script_runner( ws.local_tree(), command, subpath, commit_pending, None, None, extra_env, std::process::Stdio::inherit(), update_changelog, ) { Ok(r) => r, Err(CommandError::ScriptMadeNoChanges) => { error!("Script did not make any changes."); return 0; } Err(e) => { error!("Script failed: {}", e); return 2; } }; let mut td = None; if build_verify { if build_target_dir.is_none() { td = Some(tempfile::tempdir().unwrap()); build_target_dir = td.as_ref().map(|td| td.path().to_path_buf()); } crate::debian::build( ws.local_tree(), subpath, builder.as_deref(), build_target_dir.as_deref(), ) .unwrap(); } enable_tag_pushing(ws.local_tree().branch().as_ref()).unwrap(); let result_ref = result.clone(); let get_commit_message = get_commit_message .take() .map(|f| move |ep: Option<&MergeProposal>| -> Option { f(&result_ref, ep) }); let result_ref = result.clone(); let publish_result = match ws.publish_changes( None, mode, name, |df, ep| get_description(&result, df, ep), get_commit_message, Some(move |ep: Option<&MergeProposal>| { if let Some(get_title) = get_title { get_title(&result_ref, ep) } else { None } }), forge.as_ref(), allow_create_proposal.map(|f| f(&result)), labels.map(|l| l.iter().map(|s| s.to_string()).collect()), Some(overwrite), existing_proposal, None, None, derived_owner, None, None, None, ) { Ok(r) => r, Err(PublishError::UnsupportedForge(_)) => { error!( "No known supported forge for {}. Run 'svp login'?", crate::vcs::full_branch_url(ws.main_branch().unwrap()), ); return 2; } Err(PublishError::InsufficientChangesForNewProposal) => { info!("Insufficient changes for a new merge proposal"); return 1; } Err(PublishError::ForgeLoginRequired) => { error!("Credentials for hosting site missing. Run 'svp login'?",); return 2; } Err(PublishError::DivergedBranches()) | Err(PublishError::UnrelatedBranchExists) => { error!("A branch exists on the server that has diverged from the local branch."); return 2; } Err(PublishError::BranchOpenError(e)) => { error!("Failed to open branch: {}", e); return 2; } Err(PublishError::EmptyMergeProposal) => { error!("No changes to publish."); return 2; } Err(PublishError::PermissionDenied) => { error!("Permission denied to create merge proposal."); return 2; } Err(PublishError::Other(e)) => { error!("Failed to publish changes: {}", e); return 2; } Err(PublishError::NoTargetBranch) => { unreachable!() } }; if let Some(mp) = publish_result.proposal { if publish_result.is_new.unwrap() { info!("Merge proposal created."); } else { info!("Merge proposal updated."); } if let Ok(url) = mp.url() { info!("URL: {}", url); } info!("Description: {}", mp.get_description().unwrap().unwrap()); } if diff { ws.show_diff(Box::new(std::io::stdout()), None, None) .unwrap(); } if install { crate::debian::install_built_package( ws.local_tree(), subpath, build_target_dir.as_ref().unwrap(), ) .unwrap(); } if let Some(td) = td.take() { td.close().unwrap(); } 1 } silver-platter-0.5.44/src/debian/uploader.rs0000644000000000000000000014211214721061524015704 0ustar00//! Upload packages to the Debian archive. use crate::vcs::{open_branch, BranchOpenError}; use breezyshim::branch::Branch; use breezyshim::debian::apt::{Apt, LocalApt, RemoteApt}; use breezyshim::debian::error::Error as DebianError; use breezyshim::error::Error as BrzError; use breezyshim::gpg::VerificationResult; use breezyshim::revisionid::RevisionId; use breezyshim::tree::{MutableTree, Tree, WorkingTree}; use debversion::Version; use std::collections::HashMap; use std::path::Path; use std::str::FromStr; #[cfg(feature = "last-attempt-db")] use trivialdb as tdb; #[cfg(feature = "last-attempt-db")] /// Database for storing the last upload attempt time for each package. pub struct LastAttemptDatabase { db: tdb::Tdb, } #[cfg(feature = "last-attempt-db")] impl LastAttemptDatabase { /// Open the last attempt database. pub fn open(path: &Path) -> Self { Self { db: tdb::Tdb::open( path, None, tdb::Flags::empty(), libc::O_RDWR | libc::O_CREAT, 0o755, ) .unwrap(), } } /// Get the last upload attempt time for a package. pub fn get(&self, package: &str) -> Option> { let key = package.to_string().into_bytes(); self.db.fetch(&key).unwrap().map(|value| { let value = String::from_utf8(value).unwrap(); chrono::DateTime::parse_from_rfc3339(&value).unwrap() }) } /// Set the last upload attempt time for a package. pub fn set(&mut self, package: &str, value: chrono::DateTime) { let key = package.to_string().into_bytes(); let value = value.to_rfc3339(); self.db.store(&key, value.as_bytes(), None).unwrap(); } /// Set the last upload attempt time for a package to the current time. pub fn refresh(&mut self, package: &str) { self.set(package, chrono::Utc::now().into()); } } #[cfg(feature = "last-attempt-db")] impl Default for LastAttemptDatabase { fn default() -> Self { let xdg_dirs = xdg::BaseDirectories::with_prefix("silver-platter").unwrap(); let last_attempt_path = xdg_dirs.place_data_file("last-upload-attempt.tdb").unwrap(); Self::open(last_attempt_path.as_path()) } } /// debsign a changes file pub fn debsign(path: &Path, keyid: Option<&str>) -> Result<(), std::io::Error> { let mut args = vec!["debsign".to_string()]; if let Some(keyid) = keyid { args.push(format!("-k{}", keyid)); } args.push(path.file_name().unwrap().to_string_lossy().to_string()); let status = std::process::Command::new("debsign") .args(&args) .current_dir(path.parent().unwrap()) .status()?; if !status.success() { Err(std::io::Error::new( std::io::ErrorKind::Other, "debsign failed", )) } else { Ok(()) } } /// dput a changes file pub fn dput_changes(path: &Path) -> Result<(), std::io::Error> { let status = std::process::Command::new("dput") .arg(path.file_name().unwrap().to_string_lossy().to_string()) .current_dir(path.parent().unwrap()) .status()?; if !status.success() { Err(std::io::Error::new( std::io::ErrorKind::Other, "dput failed", )) } else { Ok(()) } } #[cfg(feature = "gpg")] /// Get the key IDs for Debian maintainers. pub fn get_maintainer_keys(context: &mut gpgme::Context) -> Result, gpgme::Error> { context.import("/usr/share/keyrings/debian-keyring.gpg")?; let mut ids = vec![]; for key in context.keys()? { if let Err(err) = key { eprintln!("Error getting key: {}", err); continue; } let key = key.unwrap(); if let Ok(key_id) = key.id() { ids.push(key_id.to_string()); } for subkey in key.subkeys() { if let Ok(subkey_id) = subkey.id() { ids.push(subkey_id.to_string()); } } } Ok(ids) } #[derive(Clone, Debug)] /// Result of uploading a package. pub enum UploadPackageError { /// Package was ignored. Ignored(String, Option), /// Package processing failed. ProcessingFailure(String, Option), } fn vcswatch_prescan_package( _package: &str, vw: &VcswatchEntry, exclude: Option<&[String]>, min_commit_age: Option, allowed_committers: Option<&[String]>, ) -> Result>, UploadPackageError> { if let Some(exclude) = exclude { if exclude.contains(&vw.package) { return Err(UploadPackageError::Ignored( "excluded".to_string(), Some("Excluded".to_string()), )); } } if vw.url.is_none() || vw.vcs.is_none() { return Err(UploadPackageError::ProcessingFailure( "not-in-vcs".to_string(), Some("Not in VCS".to_string()), )); } // TODO(jelmer): check autopkgtest_only ? // from debian.deb822 import Deb822 // pkg_source = Deb822(vw.controlfile) // has_testsuite = "Testsuite" in pkg_source if vw.commits == 0 { return Err(UploadPackageError::Ignored( "no-unuploaded-changes".to_string(), Some("No unuploaded changes".to_string()), )); } if vw.status.as_deref() == Some("ERROR") { log::warn!("vcswatch: unable to access {}: {:?}", vw.package, vw.error); return Err(UploadPackageError::ProcessingFailure( "vcs-inaccessible".to_string(), Some(format!("Unable to access vcs: {:?}", vw.error)), )); } if let Some(last_scan) = vw.last_scan.as_ref() { log::debug!("vcswatch last scanned at: {}", last_scan); } if vw.vcs.as_deref() == Some("Git") { if let Some(vcslog) = vw.vcslog.as_ref() { match check_git_commits(vcslog, min_commit_age, allowed_committers) { Err(RevisionRejected::CommitterNotAllowed(committer, allowed_committers)) => { log::warn!( "{}: committer {} not in allowed list: {:?}", vw.package, committer, allowed_committers, ); return Err(UploadPackageError::Ignored( "committer-not-allowed".to_string(), Some(format!( "committer {} not in allowed list: {:?}", committer, allowed_committers )), )); } Err(RevisionRejected::RecentCommits(commit_age, min_commit_age)) => { log::info!( "{}: Recent commits ({} days < {} days), skipping.", vw.package, commit_age, min_commit_age, ); return Err(UploadPackageError::Ignored( "recent-commits".to_string(), Some(format!( "Recent commits ({} days < {} days)", commit_age, min_commit_age )), )); } Ok(ts) => { return Ok(Some(ts)); } } } } Ok(None) } fn check_git_commits( vcslog: &str, min_commit_age: Option, allowed_committers: Option<&[String]>, ) -> Result, RevisionRejected> { #[allow(dead_code)] pub struct GitRevision { commit_id: String, headers: HashMap, message: String, } impl Revision for GitRevision { fn committer(&self) -> Option<&str> { GitRevision::committer(self) } fn timestamp(&self) -> chrono::DateTime { GitRevision::timestamp(self) } } impl GitRevision { pub fn committer(&self) -> Option<&str> { if let Some(committer) = self.headers.get("Committer") { Some(committer) } else { self.headers.get("Author").map(|s| s.as_str()) } } pub fn timestamp(&self) -> chrono::DateTime { let datestr = self.headers.get("Date").unwrap(); chrono::DateTime::parse_from_rfc2822(datestr) .unwrap() .to_utc() } pub fn from_lines(lines: &[&str]) -> Self { let mut commit_id: Option = None; let mut message = vec![]; let mut headers = std::collections::HashMap::new(); for (i, line) in lines.iter().enumerate() { if let Some(cid) = line.strip_prefix("commit ") { commit_id = Some(cid.to_string()); } else if line == &"" { message = lines[i + 1..].to_vec(); break; } else { let mut parts = line.split(": "); let name = parts.next().unwrap(); let value = parts.next().unwrap(); headers.insert(name.to_string(), value.to_string()); } } Self { commit_id: commit_id.unwrap(), headers, message: message.join("\n"), } } } let mut last_commit_ts: Option> = None; let mut lines: Vec = vec![]; for line in vcslog.lines() { if line.is_empty() && lines .last() .unwrap() .chars() .next() .unwrap() .is_whitespace() { let gitrev = GitRevision::from_lines( lines .iter() .map(|s| s.as_ref()) .collect::>() .as_slice(), ); if last_commit_ts.is_none() { last_commit_ts = Some(gitrev.timestamp()); } check_revision(&gitrev, min_commit_age, allowed_committers)?; lines = vec![]; } else { lines.push(line.to_string()); } } if !lines.is_empty() { let gitrev = GitRevision::from_lines( lines .iter() .map(|s| s.as_ref()) .collect::>() .as_slice(), ); if last_commit_ts.is_none() { last_commit_ts = Some(gitrev.timestamp()); } check_revision(&gitrev, min_commit_age, allowed_committers)?; } Ok(last_commit_ts.unwrap()) } trait Revision { fn committer(&self) -> Option<&str>; fn timestamp(&self) -> chrono::DateTime; } impl Revision for breezyshim::repository::Revision { fn committer(&self) -> Option<&str> { Some(self.committer.as_str()) } fn timestamp(&self) -> chrono::DateTime { chrono::DateTime::from_timestamp(self.timestamp as i64, 0).unwrap() } } /// Errors that can occur when checking a revision. pub enum RevisionRejected { /// The committer is not allowed. CommitterNotAllowed(String, Vec), /// The commit is too recent. RecentCommits(i64, i64), } /// Check whether a revision can be included in an upload. /// /// # Arguments /// * `rev` - revision to check /// * `min_commit_age` - minimum age for revisions /// * `allowed_committers` - list of allowed committers fn check_revision( rev: &dyn Revision, min_commit_age: Option, allowed_committers: Option<&[String]>, ) -> Result<(), RevisionRejected> { // TODO(jelmer): deal with timezone if let Some(min_commit_age) = min_commit_age { let commit_time = rev.timestamp(); let time_delta = chrono::Utc::now().signed_duration_since(commit_time); if time_delta.num_days() < min_commit_age { return Err(RevisionRejected::RecentCommits( time_delta.num_days(), min_commit_age, )); } } if let Some(allowed_committers) = allowed_committers.as_ref() { // TODO(jelmer): Allow tag to prevent automatic uploads let committer = rev.committer().unwrap(); let committer_email = match breezyshim::config::extract_email_address(committer) { Some(email) => email, None => { log::warn!("Unable to extract email from {}", committer); return Err(RevisionRejected::CommitterNotAllowed( committer.to_string(), allowed_committers.iter().map(|s| s.to_string()).collect(), )); } }; if !allowed_committers.contains(&committer_email) { return Err(RevisionRejected::CommitterNotAllowed( committer_email, allowed_committers.iter().map(|s| s.to_string()).collect(), )); } } Ok(()) } #[derive(serde::Deserialize)] /// Struct for vcswatch entry struct VcswatchEntry { /// Package name package: String, /// Control file vcslog: Option, /// Number of commits commits: usize, /// Control file url: Option, last_scan: Option, status: Option, error: Option, vcs: Option, archive_version: Option, } fn vcswatch_prescan_packages( packages: &[String], inc_stats: &mut dyn FnMut(&str), exclude: Option<&[String]>, min_commit_age: Option, allowed_committers: Option<&[String]>, ) -> Result<(Vec, usize, HashMap), Box> { log::info!("Using vcswatch to prescan {} packages", packages.len()); let url = url::Url::parse("https://qa.debian.org/data/vcswatch/vcswatch.json.gz")?; let client = reqwest::blocking::Client::new(); let request = client .request(reqwest::Method::GET, url) .header( "User-Agent", format!("silver-platter/{}", env!("CARGO_PKG_VERSION")), ) .build()?; let response = client.execute(request)?; assert!( response.status().is_success(), "Failed to fetch vcswatch data" ); let d = flate2::read::GzDecoder::new(response); let entries: Vec = serde_json::from_reader(d)?; let vcswatch = entries .into_iter() .map(|e| (e.package.clone(), e)) .collect::>(); let mut by_ts = HashMap::new(); let mut failures = 0; for package in packages.iter() { let vw = if let Some(p) = vcswatch.get(package) { p } else { continue; }; match vcswatch_prescan_package(package, vw, exclude, min_commit_age, allowed_committers) { Err(UploadPackageError::ProcessingFailure(reason, _description)) => { inc_stats(reason.as_str()); failures += 1; } Err(UploadPackageError::Ignored(reason, _description)) => { inc_stats(reason.as_str()); } Ok(ts) => { by_ts.insert(package, ts); } } } let mut ts_items = by_ts.into_iter().collect::>(); ts_items.sort_by(|a, b| b.1.cmp(&a.1)); let packages = ts_items .into_iter() .map(|(k, _)| k.to_string()) .collect::>(); Ok((packages, failures, vcswatch)) } fn find_last_release_revid(branch: &dyn Branch, version: &Version) -> Result { use pyo3::prelude::*; pyo3::Python::with_gil(|py| -> PyResult { let m = py.import_bound("breezy.plugins.debian.import_dsc")?; let dbc = m.getattr("DistributionBranch")?; let dbc = dbc.call1((branch.to_object(py), py.None()))?; dbc.call_method1("revid_of_version", (version.to_object(py),))? .extract::() }) .map_err(|e| BrzError::from(e)) } /// Select packages from the apt repository. fn select_apt_packages( apt_repo: &dyn Apt, package_names: Option<&[String]>, maintainer: Option<&[String]>, ) -> Vec { let mut packages = vec![]; for source in apt_repo.iter_sources() { if let Some(maintainer) = maintainer { let m = source.maintainer().unwrap(); let (_fullname, email) = debian_changelog::parseaddr(&m); if !maintainer.contains(&email.to_string()) { continue; } } if let Some(package_names) = package_names { if !package_names.contains(&source.package().unwrap()) { continue; } } packages.push(source.package().unwrap()); } packages } /// Process a package for upload. pub fn main( mut packages: Vec, acceptable_keys: Option>, gpg_verification: bool, min_commit_age: Option, diff: bool, builder: String, mut maintainer: Option>, vcswatch: bool, exclude: Option>, autopkgtest_only: bool, allowed_committers: Option>, debug: bool, shuffle: bool, verify_command: Option, apt_repository: Option, apt_repository_key: Option, ) -> Result<(), i32> { let mut ret = Ok(()); if packages.is_empty() && maintainer.is_none() { if let Some((_name, email)) = debian_changelog::get_maintainer() { log::info!("Processing packages maintained by {}", email); maintainer = Some(vec![email]); } } if !vcswatch { log::info!( "Use --vcswatch to only process packages for which vcswatch found pending commits." ) } let apt_repo: Box = if let Some(apt_repository) = apt_repository.as_ref() { Box::new(RemoteApt::from_string(apt_repository, apt_repository_key.as_deref()).unwrap()) as _ } else { Box::new(LocalApt::new(None).unwrap()) as _ }; if let Some(maintainer) = maintainer.as_ref() { packages = select_apt_packages( apt_repo.as_ref(), Some(packages.as_slice()), Some(maintainer), ); } if packages.is_empty() { log::info!("No packages found."); return Err(1); } if shuffle { use rand::seq::SliceRandom; // Shuffle packages vec let mut rng = rand::thread_rng(); packages.shuffle(&mut rng); } let mut stats = HashMap::new(); let mut inc_stats = |result: &str| { *stats.entry(result.to_string()).or_insert(0) += 1; }; let mut extra_data: Option> = None; if vcswatch { let (new_packages, failures, new_extra_data) = vcswatch_prescan_packages( packages.as_slice(), &mut &mut inc_stats, exclude.as_deref(), min_commit_age, allowed_committers.as_deref(), ) .unwrap(); packages = new_packages; extra_data = Some(new_extra_data); if failures > 0 { ret = Err(1); } }; if packages.len() > 1 { log::info!( "Uploading {} packages: {}", packages.len(), packages.join(", ") ); } #[cfg(feature = "last-attempt-db")] let mut last_attempt = LastAttemptDatabase::default(); #[cfg(feature = "last-attempt-db")] { let orig_packages = packages.clone(); let last_attempt_key = |p: &String| -> (chrono::DateTime, usize) { let t = last_attempt.get(p).unwrap_or(chrono::Utc::now().into()); (t, orig_packages.iter().position(|i| i == p).unwrap()) }; packages.sort_by_key(last_attempt_key); } for package in packages.iter() { let extra_package = extra_data.as_ref().and_then(|d| d.get(package)); match process_package( apt_repo.as_ref(), package, &builder, exclude.as_deref(), autopkgtest_only, gpg_verification, acceptable_keys.as_deref(), debug, diff, min_commit_age, allowed_committers.as_deref(), extra_package.and_then(|p| p.vcs.as_deref()), extra_package.and_then(|p| p.url.as_deref()), extra_package.map(|p| p.package.as_str()), extra_package.and_then(|p| p.archive_version.as_ref()), verify_command.as_deref(), ) { Err(UploadPackageError::ProcessingFailure(reason, _description)) => { inc_stats(reason.as_str()); ret = Err(1); } Err(UploadPackageError::Ignored(reason, _description)) => inc_stats(reason.as_str()), Ok(_) => { inc_stats("success"); } } #[cfg(feature = "last-attempt-db")] last_attempt.refresh(package); } if packages.len() > 1 { log::info!("Results:"); for (error, c) in stats.iter() { log::info!(" {}: {}", error, c); } } ret } /// Errors that can occur when preparing a package for upload. pub enum PrepareUploadError { /// Failed to run gbp dch GbpDchFailed, /// No unuploaded changes since the last upload NoUnuploadedChanges(Version), /// The last upload was more recent than the previous upload LastUploadMoreRecent(Version, Version), /// The last release revision was not found LastReleaseRevisionNotFound(String, Version), /// No unreleased changes NoUnreleasedChanges(Version), /// Generated changelog file GeneratedChangelogFile, /// No valid GPG signature NoValidGpgSignature(RevisionId, VerificationResult), /// Revision rejected Rejected(RevisionRejected), /// Build failed BuildFailed, /// Missing upstream tarball MissingUpstreamTarball(String, String), /// Package version not present PackageVersionNotPresent(String, String), /// Missing changelog MissingChangelog, /// Changelog parse error ChangelogParseError(String), /// Breezy error BrzError(BrzError), /// Debian error DebianError(DebianError), /// There is a missing nested tree MissingNestedTree(std::path::PathBuf), } impl From for PrepareUploadError { fn from(e: BrzError) -> Self { match e { BrzError::MissingNestedTree(p) => PrepareUploadError::MissingNestedTree(p), e => PrepareUploadError::BrzError(e), } } } /// Prepare a package for upload. pub fn prepare_upload_package( local_tree: &WorkingTree, subpath: &std::path::Path, pkg: &str, last_uploaded_version: Option<&debversion::Version>, builder: &str, gpg_strategy: Option, min_commit_age: Option, allowed_committers: Option<&[String]>, apt: Option<&dyn Apt>, ) -> Result<(std::path::PathBuf, Option), PrepareUploadError> { let mut builder = builder.to_string(); let debian_path = subpath.join("debian"); #[cfg(feature = "detect-update-changelog")] let run_gbp_dch = { let cl_behaviour = debian_analyzer::detect_gbp_dch::guess_update_changelog( local_tree, debian_path.as_path(), None, ); match cl_behaviour { Some(cl_behaviour) => cl_behaviour.update_changelog, None => true, } }; #[cfg(not(feature = "detect-update-changelog"))] let run_gbp_dch = false; if run_gbp_dch { match crate::debian::gbp_dch(local_tree.abspath(subpath).unwrap().as_path()) { Ok(_) => {} Err(_) => { // TODO(jelmer): gbp dch sometimes fails when there is no existing // open changelog entry; it fails invoking // "dpkg --lt None " return Err(PrepareUploadError::GbpDchFailed); } } local_tree .build_commit() .message("update changelog\n\nGbp-Dch: Ignore") .specific_files(&[&debian_path.join("changelog")]) .commit() .unwrap(); } let (cl, _top_level) = debian_analyzer::changelog::find_changelog( local_tree, std::path::Path::new(""), Some(false), ) .map_err(|e| match e { debian_analyzer::changelog::FindChangelogError::MissingChangelog(..) => { PrepareUploadError::MissingChangelog } debian_analyzer::changelog::FindChangelogError::AddChangelog(..) => { panic!("changelog not versioned - should never happen"); } debian_analyzer::changelog::FindChangelogError::ChangelogParseError(reason) => { PrepareUploadError::ChangelogParseError(reason) } debian_analyzer::changelog::FindChangelogError::BrzError(o) => { PrepareUploadError::BrzError(o) } })?; let first_block = match cl.iter().next() { Some(e) => e, None => { return Err(PrepareUploadError::NoUnuploadedChanges( last_uploaded_version.unwrap().clone(), )); } }; if let Some(last_uploaded_version) = last_uploaded_version { if let Some(first_version) = first_block.version() { if first_version == *last_uploaded_version { return Err(PrepareUploadError::NoUnuploadedChanges(first_version)); } } if let Some(previous_version_in_branch) = debian_analyzer::changelog::find_previous_upload(&cl) { if *last_uploaded_version > previous_version_in_branch { return Err(PrepareUploadError::LastUploadMoreRecent( last_uploaded_version.clone(), previous_version_in_branch, )); } } } if let Some(last_uploaded_version) = last_uploaded_version { log::info!("Checking revisions since {}", last_uploaded_version); } let lock = local_tree.lock_read(); let last_release_revid: RevisionId = if let Some(last_uploaded_version) = last_uploaded_version { match find_last_release_revid(local_tree.branch().as_ref(), last_uploaded_version) { Ok(revid) => revid, Err(BrzError::NoSuchTag(..)) => { return Err(PrepareUploadError::LastReleaseRevisionNotFound( pkg.to_string(), last_uploaded_version.clone(), )); } Err(e) => { panic!("Unexpected error: {:?}", e); } } } else { breezyshim::revisionid::RevisionId::null() }; let graph = local_tree.branch().repository().get_graph(); let revids = graph .iter_lefthand_ancestry( &local_tree.branch().last_revision(), Some(&[last_release_revid]), ) .collect::, _>>() .unwrap(); if revids.is_empty() { log::info!("No pending changes"); return Err(PrepareUploadError::NoUnuploadedChanges( first_block.version().unwrap(), )); } if let Some(gpg_strategy) = gpg_strategy { log::info!("Verifying GPG signatures..."); let result = breezyshim::gpg::bulk_verify_signatures( &local_tree.branch().repository(), revids.iter().collect::>().as_slice(), &gpg_strategy, ) .unwrap(); for (revid, result) in result { if !result.is_valid() { return Err(PrepareUploadError::NoValidGpgSignature(revid, result)); } } } for (_revid, rev) in local_tree.branch().repository().iter_revisions(revids) { if let Some(rev) = rev { check_revision(&rev, min_commit_age, allowed_committers) .map_err(PrepareUploadError::Rejected)?; } } if first_block.is_unreleased().unwrap_or(false) { return Err(PrepareUploadError::NoUnreleasedChanges( first_block.version().unwrap(), )); } std::mem::drop(lock); let mut qa_upload = false; #[allow(unused_mut)] let mut team_upload = false; let control_path = local_tree .abspath(debian_path.join("control").as_path()) .unwrap(); let mut f = local_tree.get_file_text(control_path.as_path()).unwrap(); let control = debian_control::Control::from_str(std::str::from_utf8_mut(f.as_mut_slice()).unwrap()) .unwrap(); let source = control.source().unwrap(); let maintainer = source.maintainer().unwrap(); let (_, e) = debian_changelog::parseaddr(&maintainer); if e == "packages@qa.debian.org" { qa_upload = true; // TODO(jelmer): Check whether this is a team upload // TODO(jelmer): determine whether this is a NMU upload } if qa_upload || team_upload { let changelog_path = local_tree.abspath(&debian_path.join("changelog")).unwrap(); let f = local_tree.get_file(changelog_path.as_path()).unwrap(); let cl = debian_changelog::ChangeLog::read_relaxed(f).unwrap(); let message = if qa_upload { Some("QA Upload.") } else if team_upload { Some("Team Upload.") } else { None }; if let Some(message) = message { cl.iter().next().unwrap().ensure_first_line("Team upload."); local_tree .put_file_bytes_non_atomic(changelog_path.as_path(), cl.to_string().as_bytes()) .unwrap(); // TODO: Use NullCommitReporter local_tree .build_commit() .message(&format!("Mention {}", message)) .allow_pointless(true) .specific_files(&[debian_path.join("changelog").as_path()]) .commit() .unwrap(); } } let tag_name = match breezyshim::debian::release::release(local_tree, subpath) { Ok(tag_name) => tag_name, Err(breezyshim::debian::release::ReleaseError::GeneratedFile) => { return Err(PrepareUploadError::GeneratedChangelogFile); } Err(e) => { panic!("Unexpected error: {:?}", e); } }; let target_dir = tempfile::tempdir().unwrap(); if let Some(last_uploaded_version) = last_uploaded_version { builder = builder.replace( "${LAST_VERSION}", last_uploaded_version.to_string().as_str(), ); } let target_changes = breezyshim::debian::build_helper( local_tree, subpath, local_tree.branch().as_ref(), target_dir.path(), builder.as_str(), false, apt, ) .map_err(|e| match e { DebianError::BrzError(o) => PrepareUploadError::BrzError(o), DebianError::MissingUpstreamTarball { package, version } => { PrepareUploadError::MissingUpstreamTarball(package, version) } DebianError::PackageVersionNotPresent { package, version } => { PrepareUploadError::PackageVersionNotPresent(package, version) } DebianError::BuildFailed => PrepareUploadError::BuildFailed, e => PrepareUploadError::DebianError(e), })?; let source = target_changes.get("source").unwrap(); debsign(std::path::Path::new(&source), None).unwrap(); Ok((source.into(), Some(tag_name))) } /// Process a package for upload. pub fn process_package( apt_repo: &dyn Apt, package: &str, builder: &str, exclude: Option<&[String]>, autopkgtest_only: bool, gpg_verification: bool, acceptable_keys: Option<&[String]>, _debug: bool, diff: bool, min_commit_age: Option, allowed_committers: Option<&[String]>, vcs_type: Option<&str>, vcs_url: Option<&str>, source_name: Option<&str>, archive_version: Option<&debversion::Version>, verify_command: Option<&str>, ) -> Result<(), UploadPackageError> { let mut archive_version = archive_version.cloned(); let mut source_name = source_name.map(|s| s.to_string()); let mut vcs_type = vcs_type.map(|s| s.to_string()); let mut vcs_url = vcs_url.map(|s| s.to_string()); let exclude = exclude.unwrap_or(&[]); log::info!("Processing {}", package); // Can't use open_packaging_branch here, since we want to use pkg_source later on. let mut has_testsuite; if !package.contains('/') { let pkg_source = match crate::debian::apt_get_source_package(apt_repo, package) { Some(pkg_source) => pkg_source, None => { log::info!("{}: package not found in apt", package); return Err(UploadPackageError::ProcessingFailure( "not-in-apt".to_string(), Some("Package not found in apt".to_string()), )); } }; if vcs_type.is_none() || vcs_url.is_none() { (vcs_type, vcs_url) = match debian_analyzer::vcs::vcs_field(&pkg_source) { Some((t, u)) => (Some(t), Some(u)), None => { log::info!( "{}: no declared vcs location, skipping", pkg_source.package().unwrap() ); return Err(UploadPackageError::ProcessingFailure( "not-in-vcs".to_string(), Some("No declared vcs location".to_string()), )); } }; } source_name = Some(source_name.unwrap_or_else(|| pkg_source.package().unwrap())); if exclude.contains(source_name.as_ref().unwrap()) { return Err(UploadPackageError::Ignored("excluded".to_string(), None)); } archive_version = Some(archive_version.unwrap_or_else(|| pkg_source.version().unwrap())); has_testsuite = Some(pkg_source.testsuite().is_some()); } else { vcs_url = Some(vcs_url.unwrap_or(package.to_owned())); has_testsuite = None; } let parsed_vcs: debian_control::vcs::ParsedVcs = vcs_url.as_ref().unwrap().parse().unwrap(); let location: url::Url = parsed_vcs.repo_url.parse().unwrap(); let branch_name = parsed_vcs.branch; let subpath = std::path::PathBuf::from(parsed_vcs.subpath.unwrap_or("".to_string())); let probers = crate::probers::select_probers(vcs_type.as_deref()); let main_branch = match open_branch( &location, None, Some( probers .iter() .map(|p| p.as_ref()) .collect::>() .as_slice(), ), branch_name.as_deref(), ) { Ok(b) => b, Err( BranchOpenError::Unavailable { description, .. } | BranchOpenError::TemporarilyUnavailable { description, .. }, ) => { log::info!( "{}: branch unavailable: {}", vcs_url.as_ref().unwrap(), description ); return Err(UploadPackageError::ProcessingFailure( "vcs-inaccessible".to_string(), Some(format!("Unable to access vcs: {:?}", description)), )); } Err(BranchOpenError::RateLimited { url: _, description: _, retry_after, }) => { log::info!( "{}: rate limited by server (retrying after {})", vcs_url.unwrap(), retry_after.map_or("unknown".to_string(), |i| i.to_string()) ); return Err(UploadPackageError::ProcessingFailure( "rate-limited".to_string(), Some(format!( "Rate limited by server (retrying after {})", retry_after.map_or("unknown".to_string(), |i| i.to_string()) )), )); } Err(BranchOpenError::Missing { description, .. }) => { log::info!("{}: branch not found: {}", vcs_url.unwrap(), description); return Err(UploadPackageError::ProcessingFailure( "vcs-inaccessible".to_string(), Some(format!("Unable to access vcs: {:?}", description)), )); } Err(BranchOpenError::Other(description)) => { log::info!( "{}: error opening branch: {}", vcs_url.unwrap(), description ); return Err(UploadPackageError::ProcessingFailure( "vcs-error".to_string(), Some(format!("Unable to access vcs: {:?}", description)), )); } Err(BranchOpenError::Unsupported { description, .. }) => { log::info!("{}: branch not found: {}", vcs_url.unwrap(), description); return Err(UploadPackageError::ProcessingFailure( "vcs-unsupported".to_string(), Some(format!("Unable to access vcs: {:?}", description)), )); } }; let mut ws_builder = crate::workspace::Workspace::builder(); ws_builder = ws_builder.additional_colocated_branches( crate::debian::pick_additional_colocated_branches(main_branch.as_ref()), ); let ws = ws_builder.main_branch(main_branch).build().unwrap(); if source_name.is_none() { let control_path = subpath.join("debian/control"); let control_text = ws .local_tree() .get_file_text(control_path.as_path()) .unwrap(); let control = debian_control::Control::from_str( std::str::from_utf8(control_text.as_slice()).unwrap(), ) .unwrap(); let source_name = control.source().unwrap().name().unwrap(); let pkg_source = match crate::debian::apt_get_source_package(apt_repo, &source_name) { Some(p) => p, None => { log::info!("{}: package not found in apt", package); return Err(UploadPackageError::ProcessingFailure( "not-in-apt".to_owned(), Some("Package not found in apt".to_owned()), )); } }; archive_version = pkg_source.version(); has_testsuite = Some(control.source().unwrap().testsuite().is_some()); } let has_testsuite = has_testsuite.unwrap(); let source_name = source_name.unwrap(); if exclude.contains(&source_name) { return Err(UploadPackageError::Ignored("excluded".to_string(), None)); } if autopkgtest_only && !has_testsuite && !ws .local_tree() .has_filename(&subpath.join("debian/tests/control")) { log::info!("{}: Skipping, package has no autopkgtest.", source_name); return Err(UploadPackageError::Ignored( "no-autopkgtest".to_owned(), None, )); } let branch_config = ws.local_tree().branch().get_config(); let gpg_strategy = if gpg_verification { let gpg_strategy = breezyshim::gpg::GPGStrategy::new(&branch_config); let acceptable_keys = if let Some(acceptable_keys) = acceptable_keys { acceptable_keys.iter().map(|s| s.to_string()).collect() } else { #[cfg(feature = "gpg")] { let mut context = gpgme::Context::from_protocol(gpgme::Protocol::OpenPgp).unwrap(); get_maintainer_keys(&mut context).unwrap() } #[cfg(not(feature = "gpg"))] { vec![] } }; gpg_strategy.set_acceptable_keys(acceptable_keys.as_slice()); Some(gpg_strategy) } else { None }; let (target_changes, tag_name) = match prepare_upload_package( ws.local_tree(), std::path::Path::new(&subpath), &source_name, archive_version.as_ref(), builder, gpg_strategy, min_commit_age, allowed_committers, Some(apt_repo), ) { Ok(r) => r, Err(PrepareUploadError::GbpDchFailed) => { log::warn!("{}: 'gbp dch' failed to run", source_name); return Err(UploadPackageError::ProcessingFailure( "gbp-dch-failed".to_string(), None, )); } Err(PrepareUploadError::MissingUpstreamTarball(package, version)) => { log::warn!( "{}: missing upstream tarball: {} {}", source_name, package, version ); return Err(UploadPackageError::ProcessingFailure( "missing-upstream-tarball".to_string(), Some(format!("Missing upstream tarball: {} {}", package, version)), )); } Err(PrepareUploadError::Rejected(RevisionRejected::CommitterNotAllowed( committer, allowed_committers, ))) => { log::warn!( "{}: committer {} not in allowed list: {:?}", source_name, committer, allowed_committers, ); return Err(UploadPackageError::Ignored( "committer-not-allowed".to_string(), Some(format!( "committer {} not in allowed list: {:?}", committer, allowed_committers )), )); } Err(PrepareUploadError::BuildFailed) => { log::warn!("{}: package failed to build", source_name); return Err(UploadPackageError::ProcessingFailure( "build-failed".to_string(), None, )); } Err(PrepareUploadError::LastReleaseRevisionNotFound(source_name, version)) => { log::warn!( "{}: Unable to find revision matching last release {}, skipping.", source_name, version, ); return Err(UploadPackageError::ProcessingFailure( "last-release-missing".to_string(), Some(format!( "Unable to find revision matching last release {}", version )), )); } Err(PrepareUploadError::LastUploadMoreRecent(archive_version, vcs_version)) => { log::warn!( "{}: Last upload ({}) was more recent than VCS ({})", source_name, archive_version, vcs_version, ); return Err(UploadPackageError::ProcessingFailure( "last-upload-not-in-vcs".to_string(), Some(format!( "Last upload ({}) was more recent than VCS ({})", archive_version, vcs_version )), )); } Err(PrepareUploadError::ChangelogParseError(reason)) => { log::info!("{}: Error parsing changelog: {}", source_name, reason); return Err(UploadPackageError::ProcessingFailure( "changelog-parse-error".to_string(), Some(reason), )); } Err(PrepareUploadError::MissingChangelog) => { log::info!("{}: No changelog found, skipping.", source_name); return Err(UploadPackageError::ProcessingFailure( "missing-changelog".to_string(), None, )); } Err(PrepareUploadError::GeneratedChangelogFile) => { log::info!( "{}: Changelog is generated and unable to update, skipping.", source_name, ); return Err(UploadPackageError::ProcessingFailure( "generated-changelog".to_string(), None, )); } Err(PrepareUploadError::Rejected(RevisionRejected::RecentCommits( commit_age, _max_commit_age, ))) => { log::info!( "{}: Recent commits ({} days), skipping.", source_name, commit_age, ); return Err(UploadPackageError::Ignored( "recent-commits".to_string(), Some(format!("Recent commits ({} days)", commit_age)), )); } Err(PrepareUploadError::NoUnuploadedChanges(_version)) => { log::info!("{}: No unuploaded changes, skipping.", source_name,); return Err(UploadPackageError::Ignored( "no-unuploaded-changes".to_string(), Some("No unuploaded changes".to_string()), )); } Err(PrepareUploadError::NoUnreleasedChanges(_version)) => { log::info!("{}: No unreleased changes, skipping.", source_name,); return Err(UploadPackageError::Ignored( "no-unreleased-changes".to_string(), Some("No unreleased changes".to_string()), )); } Err(PrepareUploadError::MissingNestedTree(_)) => { log::error!("{}: missing nested tree", source_name); return Err(UploadPackageError::ProcessingFailure( "missing-nested-tree".to_string(), None, )); } Err(PrepareUploadError::BrzError(e)) => { log::error!("{}: error: {:?}", source_name, e); return Err(UploadPackageError::ProcessingFailure( "vcs-error".to_string(), Some(format!("{:?}", e)), )); } Err(PrepareUploadError::DebianError(e)) => { log::error!("{}: error: {:?}", source_name, e); return Err(UploadPackageError::ProcessingFailure( "debian-error".to_string(), Some(format!("{:?}", e)), )); } Err(PrepareUploadError::NoValidGpgSignature(revid, _code)) => { log::info!( "{}: No valid GPG signature for revision {}", source_name, revid ); return Err(UploadPackageError::ProcessingFailure( "no-valid-gpg-signature".to_string(), Some(format!("No valid GPG signature for revision {}", revid)), )); } Err(PrepareUploadError::PackageVersionNotPresent(package, version)) => { log::warn!( "{}: package version {} not present in repository", package, version ); return Err(UploadPackageError::ProcessingFailure( "package-version-not-present".to_string(), Some(format!( "Package version {} not present in repository", version )), )); } }; if let Some(verify_command) = verify_command { match std::process::Command::new(verify_command) .arg(&target_changes) .status() { Ok(o) => { if o.code() == Some(1) { return Err(UploadPackageError::Ignored( "verify-command-declined".to_string(), Some(format!( "{}: Verify command {} declined upload", source_name, verify_command )), )); } else if o.code() != Some(0) { return Err(UploadPackageError::ProcessingFailure( "verify-command-error".to_string(), Some(format!( "{}: Error running verify command {}: returncode {}", source_name, verify_command, o.code().unwrap() )), )); } } Err(e) => { return Err(UploadPackageError::ProcessingFailure( "verify-command-error".to_string(), Some(format!( "{}: Error running verify command {}: {}", source_name, verify_command, e )), )); } } } let mut tags = HashMap::new(); if let Some(tag_name) = tag_name.as_ref() { log::info!("Pushing tag {}", tag_name); tags.insert( tag_name.to_string(), ws.local_tree() .branch() .tags() .unwrap() .lookup_tag(tag_name) .unwrap(), ); } match ws.push(Some(tags)) { Ok(_) => {} Err(crate::workspace::Error::PermissionDenied(..)) => { log::info!( "{}: Permission denied pushing to branch, skipping.", source_name, ); return Err(UploadPackageError::ProcessingFailure( "vcs-permission-denied".to_string(), None, )); } Err(e) => { log::error!("{}: Error pushing: {}", source_name, e); return Err(UploadPackageError::ProcessingFailure( "push-error".to_string(), Some(format!("{:?}", e)), )); } } dput_changes(&target_changes).unwrap(); if diff { ws.show_diff(Box::new(std::io::stdout()), None, None) .unwrap(); } std::mem::drop(ws); Ok(()) } silver-platter-0.5.44/svp-client/Cargo.toml0000644000000000000000000000053314721061524015526 0ustar00[package] name = "svp-client" version = "0.2.0" authors = ["Jelmer Vernooij "] edition = "2021" license = "Apache-2.0" description = "Client for the silver-platter protocol" [lib] [dependencies] log = ">=0.4" serde = { workspace = true, features = ["derive"] } serde_json = "1" url = { workspace = true, features = ["serde"] } silver-platter-0.5.44/svp-client/src/0000755000000000000000000000000014721061524014364 5ustar00silver-platter-0.5.44/svp-client/src/lib.rs0000644000000000000000000001605514721061524015507 0ustar00//! # svp-client //! //! `svp-client` is a library to interact with the [SVP //! protocol](https://github.com/jelmer/silver-platter/blob/master/codemod-protocol.md), as supported by //! the `svp` command-line tool. use std::collections::HashMap; #[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] /// Behaviour for updating the changelog. pub struct ChangelogBehaviour { #[serde(rename = "update")] /// Whether the changelog should be updated. pub update_changelog: bool, /// Explanation for the decision. pub explanation: String, } #[derive(Debug, serde::Serialize)] struct Failure { pub result_code: String, pub versions: HashMap, pub description: String, pub transient: Option, } impl std::fmt::Display for Failure { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}: {}", self.result_code, self.description) } } impl std::error::Error for Failure {} impl std::fmt::Display for Success { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "Success") } } #[derive(Debug, serde::Serialize)] struct DebianContext { pub changelog: Option, } #[derive(Debug, serde::Serialize)] struct Success { pub versions: HashMap, pub value: Option, pub context: Option, pub debian: Option, #[serde(rename = "target-branch-url")] pub target_branch_url: Option, #[serde(rename = "commit-message")] pub commit_message: Option, } /// Write a success to the SVP API fn write_svp_success(data: &Success) -> std::io::Result<()> { if enabled() { let f = std::fs::File::create(std::env::var("SVP_RESULT").unwrap()).unwrap(); Ok(serde_json::to_writer(f, data)?) } else { Ok(()) } } /// Write a failure to the SVP API fn write_svp_failure(data: &Failure) -> std::io::Result<()> { if enabled() { let f = std::fs::File::create(std::env::var("SVP_RESULT").unwrap()).unwrap(); Ok(serde_json::to_writer(f, data)?) } else { Ok(()) } } /// Report success pub fn report_success(versions: HashMap, value: Option, context: Option) where T: serde::Serialize, { write_svp_success(&Success { versions, value, context: context.map(|x| serde_json::to_value(x).unwrap()), debian: None, target_branch_url: None, commit_message: None, }) .unwrap(); } /// Report success with Debian-specific context pub fn report_success_debian( versions: HashMap, value: Option, context: Option, changelog: Option, ) where T: serde::Serialize, { write_svp_success(&Success { versions, value, context: context.map(|x| serde_json::to_value(x).unwrap()), debian: Some(DebianContext { changelog }), target_branch_url: None, commit_message: None, }) .unwrap(); } /// Report that there is nothing to do pub fn report_nothing_to_do( versions: HashMap, description: Option<&str>, hint: Option<&str>, ) -> ! { let description = description.unwrap_or("Nothing to do"); write_svp_failure(&Failure { result_code: "nothing-to-do".to_string(), versions, description: description.to_string(), transient: None, }) .unwrap(); log::error!("{}", description); if let Some(hint) = hint { log::info!("{}", hint); } std::process::exit(0); } /// Report a fatal error pub fn report_fatal( versions: HashMap, code: &str, description: &str, hint: Option<&str>, transient: Option, ) -> ! { write_svp_failure(&Failure { result_code: code.to_string(), versions, description: description.to_string(), transient, }) .unwrap(); log::error!("{}", description); if let Some(hint) = hint { log::info!("{}", hint); } std::process::exit(1); } /// Load the resume file if it exists pub fn load_resume() -> Option { if enabled() { if let Ok(resume_path) = std::env::var("SVP_RESUME") { let f = std::fs::File::open(resume_path).unwrap(); let resume: T = serde_json::from_reader(f).unwrap(); Some(resume) } else { None } } else { None } } /// Check if the SVP API is enabled pub fn enabled() -> bool { std::env::var("SVP_API").ok().as_deref() == Some("1") } /// A reporter for the SVP API pub struct Reporter { versions: HashMap, target_branch_url: Option, commit_message: Option, } impl Reporter { /// Create a new reporter pub fn new(versions: HashMap) -> Self { Self { versions, target_branch_url: None, commit_message: None, } } /// Check if the SVP API is enabled pub fn enabled(&self) -> bool { enabled() } /// Load the resume file if it exists pub fn load_resume(&self) -> Option { load_resume() } /// Set the target branch URL pub fn set_target_branch_url(&mut self, url: url::Url) { self.target_branch_url = Some(url); } /// Set the commit message pub fn set_commit_message(&mut self, message: String) { self.commit_message = Some(message); } /// Report success pub fn report_success(self, value: Option, context: Option) where T: serde::Serialize, { write_svp_success(&Success { versions: self.versions, value, context: context.map(|x| serde_json::to_value(x).unwrap()), debian: None, target_branch_url: self.target_branch_url, commit_message: self.commit_message, }) .unwrap(); } /// Report success with Debian-specific context pub fn report_success_debian( self, value: Option, context: Option, changelog: Option, ) where T: serde::Serialize, { write_svp_success(&Success { versions: self.versions, value, context: context.map(|x| serde_json::to_value(x).unwrap()), debian: Some(DebianContext { changelog }), target_branch_url: self.target_branch_url, commit_message: self.commit_message, }) .unwrap(); } /// Report that there is nothing to do pub fn report_nothing_to_do(self, description: Option<&str>, hint: Option<&str>) -> ! { report_nothing_to_do(self.versions, description, hint); } /// Report a fatal error pub fn report_fatal( self, code: &str, description: &str, hint: Option<&str>, transient: Option, ) -> ! { report_fatal(self.versions, code, description, hint, transient); } } silver-platter-0.5.44/svp-py/Cargo.toml0000644000000000000000000000157314721061524014705 0ustar00[package] name = "svp-py" version = "0.0.0" authors = ["Jelmer Vernooij "] edition = "2018" license = "Apache-2.0" repository = "https://github.com/jelmer/silver-platter.git" homepage = "https://github.com/jelmer/silver-platter" publish = false [lib] crate-type = ["cdylib"] [features] debian = ["silver-platter/debian"] extension-module = ["pyo3/extension-module"] default = ["debian"] [dependencies] silver-platter = { path = "..", default-features = false, features = ["pyo3", "detect-update-changelog"] } pyo3 = { workspace = true, features = ["abi3"] } pyo3-log = { workspace = true } tera = { workspace = true } serde_json = { workspace = true } url = { workspace = true, features = ["serde"] } breezyshim = { workspace = true } debian-changelog = { workspace = true } pyo3-filelike = "0.3.0" [package.metadata.cargo-all-features] denylist = ["extension-module"] silver-platter-0.5.44/svp-py/src/0000755000000000000000000000000014721061524013536 5ustar00silver-platter-0.5.44/svp-py/src/lib.rs0000644000000000000000000012616414721061524014664 0ustar00use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyDict, PyType}; use pyo3::{create_exception, import_exception}; use silver_platter::codemod::Error as CodemodError; use silver_platter::{CommitPending, Mode}; use silver_platter::{RevisionId, WorkingTree}; use std::collections::HashMap; use std::os::unix::io::FromRawFd; use std::path::{Path, PathBuf}; create_exception!( silver_platter, UnrelatedBranchExists, pyo3::exceptions::PyException ); create_exception!( silver_platter, PreCheckFailed, pyo3::exceptions::PyException ); create_exception!( silver_platter, PostCheckFailed, pyo3::exceptions::PyException ); create_exception!( silver_platter, ScriptMadeNoChanges, pyo3::exceptions::PyException ); create_exception!(silver_platter, ScriptFailed, pyo3::exceptions::PyException); create_exception!( silver_platter, ScriptNotFound, pyo3::exceptions::PyException ); create_exception!( silver_platter, DetailedFailure, pyo3::exceptions::PyException ); create_exception!( silver_platter, ResultFileFormatError, pyo3::exceptions::PyException ); create_exception!( silver_platter, InsufficientChangesForNewProposal, pyo3::exceptions::PyException ); create_exception!( silver_platter, EmptyMergeProposal, pyo3::exceptions::PyException ); create_exception!( silver_platter, MissingChangelog, pyo3::exceptions::PyException ); import_exception!(breezy.errors, DivergedBranches); create_exception!( silver_platter, NoTargetBranch, pyo3::exceptions::PyException ); #[pyclass] struct Recipe(silver_platter::recipe::Recipe); fn json_to_py<'a, 'b, 'py>(py: Python<'py>, value: &'b serde_json::Value) -> Bound<'a, PyAny> where 'py: 'a, { match value { serde_json::Value::Null => py.None().into_bound(py), serde_json::Value::Bool(b) => { let o = pyo3::types::PyBool::new_bound(py, *b).into_py(py); o.into_bound(py) } serde_json::Value::Number(n) => { let n: PyObject = if let Some(n) = n.as_u64() { n.into_py(py) } else if let Some(n) = n.as_i64() { n.into_py(py) } else if let Some(n) = n.as_f64() { n.into_py(py) } else { unreachable!() }; n.into_bound(py) } serde_json::Value::String(s) => pyo3::types::PyString::new_bound(py, s.as_str()).into_any(), serde_json::Value::Array(a) => { let list = pyo3::types::PyList::empty_bound(py); for v in a { list.append(json_to_py(py, v)).unwrap(); } list.into_any() } serde_json::Value::Object(o) => { let dict = pyo3::types::PyDict::new_bound(py); for (k, v) in o { dict.set_item(k, json_to_py(py, v)).unwrap(); } dict.into_any() } } } fn py_to_json(obj: &Bound) -> PyResult { if obj.is_none() { Ok(serde_json::Value::Null) } else if let Ok(b) = obj.downcast::() { Ok(serde_json::Value::Bool(b.is_true())) } else if let Ok(f) = obj.downcast::() { Ok(serde_json::Value::Number( serde_json::Number::from_f64(f.value()).unwrap(), )) } else if let Ok(s) = obj.downcast::() { Ok(serde_json::Value::String(s.to_string_lossy().to_string())) } else if let Ok(l) = obj.downcast::() { Ok(serde_json::Value::Array( l.iter() .map(|x| py_to_json(&x)) .collect::>>()?, )) } else if let Ok(d) = obj.downcast::() { let mut ret = serde_json::Map::new(); for (k, v) in d.iter() { let k = k.extract::()?; let v = py_to_json(&v)?; ret.insert(k, v); } Ok(serde_json::Value::Object(ret)) } else { Err(PyTypeError::new_err(("unsupported type",))) } } #[pymethods] impl Recipe { #[classmethod] fn from_path(_type: &Bound, path: PathBuf) -> PyResult { let recipe = silver_platter::recipe::Recipe::from_path(path.as_path())?; Ok(Recipe(recipe)) } #[getter] fn name(&self) -> Option<&str> { self.0.name.as_deref() } #[getter] fn resume(&self) -> Option { self.0.resume } #[getter] fn labels(&self) -> Option> { self.0.labels.clone() } #[getter] fn commit_pending(&self) -> Option { match self.0.commit_pending { CommitPending::Auto => None, CommitPending::Yes => Some(true), CommitPending::No => Some(false), } } #[getter] fn command(&self) -> Option> { self.0.command.as_ref().map(|v| v.argv()) } #[getter] fn mode(&self) -> Option { self.0.mode.as_ref().map(|m| m.to_string()) } fn render_merge_request_title(&self, context: &Bound) -> PyResult> { let merge_request = if let Some(mp) = self.0.merge_request.as_ref() { mp } else { return Ok(None); }; let context = py_dict_to_tera_context(context)?; merge_request.render_title(&context).map_err(|e| { PyRuntimeError::new_err(format!("Failed to render merge request title: {}", e)) }) } fn render_merge_request_commit_message( &self, context: &Bound, ) -> PyResult> { let merge_request = if let Some(mp) = self.0.merge_request.as_ref() { mp } else { return Ok(None); }; let context = py_dict_to_tera_context(context)?; merge_request.render_commit_message(&context).map_err(|e| { PyRuntimeError::new_err(format!( "Failed to render merge request commit message: {}", e )) }) } fn render_merge_request_description( &self, format: &str, context: &Bound, ) -> PyResult> { let merge_request = if let Some(mp) = self.0.merge_request.as_ref() { mp } else { return Ok(None); }; let context = py_dict_to_tera_context(context)?; let format = match format { "markdown" => silver_platter::proposal::DescriptionFormat::Markdown, "html" => silver_platter::proposal::DescriptionFormat::Html, "plain" => silver_platter::proposal::DescriptionFormat::Plain, _ => { return Err(PyValueError::new_err(format!( "Invalid merge request description format: {}", format ))) } }; merge_request .render_description(format, &context) .map_err(|e| { PyRuntimeError::new_err(format!( "Failed to render merge request description: {}", e )) }) } } fn py_dict_to_tera_context(py_dict: &Bound) -> PyResult { let mut context = tera::Context::new(); if py_dict.is_none() { return Ok(context); } let py_dict = py_dict.extract::>()?; for (key, value) in py_dict.iter() { let key = key.extract::()?; if let Ok(value) = value.extract::() { context.insert(key, &value); } else if let Ok(value) = value.extract::() { context.insert(key, &value); } else { return Err(PyTypeError::new_err(format!( "Unsupported type for key '{}'", key ))); } } Ok(context) } #[pyfunction] fn derived_branch_name(url: &str) -> PyResult<&str> { let branch_name = silver_platter::derived_branch_name(url); Ok(branch_name) } #[pyclass] struct CommandResult(silver_platter::codemod::CommandResult); #[pymethods] impl CommandResult { #[getter] fn value(&self) -> Option { self.0.value } #[getter] fn description(&self) -> Option<&str> { self.0.description.as_deref() } #[getter] fn serialized_context(&self) -> Option<&str> { self.0.serialized_context.as_deref() } #[getter] fn tags(&self) -> Vec<(String, Option)> { self.0.tags.clone() } #[getter] fn target_branch_url(&self) -> Option<&str> { self.0.target_branch_url.as_ref().map(|u| u.as_str()) } #[getter] fn old_revision(&self) -> RevisionId { self.0.old_revision.clone() } #[getter] fn new_revision(&self) -> RevisionId { self.0.new_revision.clone() } #[getter] fn context<'a, 'py>(&self, py: Python<'py>) -> Option> where 'py: 'a, { self.0.context.as_ref().map(|c| json_to_py(py, c)) } } #[pyfunction] #[pyo3(signature = (local_tree, script, subpath=None, commit_pending=None, resume_metadata=None, committer=None, extra_env=None, stderr=None))] fn script_runner( py: Python, local_tree: PyObject, script: PyObject, subpath: Option, commit_pending: Option, resume_metadata: Option, committer: Option<&str>, extra_env: Option>, stderr: Option, ) -> PyResult { let script = if let Ok(script) = script.extract::>(py) { script } else { vec![ "sh".to_string(), "-c".to_string(), script.extract::(py)?, ] }; silver_platter::codemod::script_runner( &WorkingTree::from(local_tree), script .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), subpath .as_ref() .map_or_else(|| std::path::Path::new(""), |p| p.as_path()), match commit_pending { None => CommitPending::Auto, Some(true) => CommitPending::Yes, Some(false) => CommitPending::No, }, resume_metadata .map(|m| py_to_json(m.bind(py)).unwrap()) .as_ref(), committer, extra_env, if let Some(stderr) = stderr { let fd = stderr .call_method0(py, "fileno")? .extract::(py) .unwrap(); let f = unsafe { std::fs::File::from_raw_fd(fd) }; std::process::Stdio::from(f) } else { std::process::Stdio::inherit() }, ) .map(|result| CommandResult(result).into_py(py)) .map_err(|err| match err { CodemodError::ScriptMadeNoChanges => ScriptMadeNoChanges::new_err("Script made no changes"), CodemodError::ExitCode(code) => { ScriptFailed::new_err(format!("Script failed with exit code {}", code)) } CodemodError::ScriptNotFound => ScriptNotFound::new_err("Script not found"), CodemodError::Detailed(df) => { DetailedFailure::new_err(format!("Script failed: {}", df.description.unwrap())) } CodemodError::Json(err) => { ResultFileFormatError::new_err(format!("Result file format error: {}", err)) } CodemodError::Io(err) => err.into(), CodemodError::Other(err) => PyRuntimeError::new_err(format!("Script failed: {}", err)), CodemodError::Utf8(err) => err.into(), }) } #[pyclass] struct Forge(silver_platter::Forge); #[pyfunction] #[pyo3(signature = (local_branch, main_branch, forge, name, overwrite_existing=None, owner=None, tags=None, stop_revision=None))] fn push_derived_changes( py: Python, local_branch: PyObject, main_branch: PyObject, forge: PyObject, name: &str, overwrite_existing: Option, owner: Option<&str>, tags: Option>, stop_revision: Option, ) -> PyResult<(PyObject, String)> { let (b, u) = silver_platter::publish::push_derived_changes( &silver_platter::GenericBranch::new(local_branch), &silver_platter::GenericBranch::new(main_branch), &silver_platter::Forge::from(forge), name, overwrite_existing, owner, tags, stop_revision.as_ref(), )?; Ok((b.to_object(py), u.to_string())) } #[pyclass] struct CandidateList(silver_platter::candidates::Candidates); #[pymethods] impl CandidateList { #[classmethod] fn from_path(_type: &Bound, path: PathBuf) -> PyResult { Ok(Self(silver_platter::candidates::Candidates::from_path( path.as_path(), )?)) } #[getter] fn candidates(&self) -> Vec { self.0 .candidates() .iter() .map(|c| Candidate(c.clone())) .collect() } } #[pyclass] struct Candidate(silver_platter::candidates::Candidate); #[pymethods] impl Candidate { #[getter] fn url(&self) -> &str { self.0.url.as_str() } #[getter] fn name(&self) -> Option<&str> { self.0.name.as_deref() } #[getter] fn branch(&self) -> Option<&str> { self.0.branch.as_deref() } #[getter] fn subpath(&self) -> Option<&Path> { self.0.subpath.as_deref() } #[getter] fn default_mode(&self) -> Option { self.0.default_mode.as_ref().map(|m| m.to_string()) } } #[pyfunction] #[pyo3(signature = (local_branch, main_branch, forge=None, possible_transports=None, additional_colocated_branches=None, tags=None, stop_revision=None))] fn push_changes( local_branch: PyObject, main_branch: PyObject, forge: Option, possible_transports: Option>, additional_colocated_branches: Option>, tags: Option>, stop_revision: Option, ) -> PyResult<()> { let mut possible_transports: Option> = possible_transports.map(|t| t.into_iter().map(silver_platter::Transport::new).collect()); silver_platter::publish::push_changes( &silver_platter::GenericBranch::new(local_branch), &silver_platter::GenericBranch::new(main_branch), forge.map(silver_platter::Forge::from).as_ref(), possible_transports.as_mut(), additional_colocated_branches, tags, stop_revision.as_ref(), )?; Ok(()) } #[pyfunction] #[pyo3(signature = (local_branch, remote_branch, additional_colocated_branches=None, tags=None, stop_revision=None))] fn push_result( local_branch: PyObject, remote_branch: PyObject, additional_colocated_branches: Option>, tags: Option>, stop_revision: Option, ) -> PyResult<()> { silver_platter::publish::push_result( &silver_platter::GenericBranch::new(local_branch), &silver_platter::GenericBranch::new(remote_branch), additional_colocated_branches, tags, stop_revision.as_ref(), )?; Ok(()) } #[pyfunction] fn full_branch_url(branch: PyObject) -> PyResult { Ok( silver_platter::vcs::full_branch_url(&silver_platter::GenericBranch::new(branch)) .to_string(), ) } #[pyclass] struct MergeProposal(silver_platter::MergeProposal); #[pyfunction] #[pyo3(signature = (main_branch, forge, name, overwrite_unrelated, owner=None, preferred_schemes=None))] fn find_existing_proposed( py: Python, main_branch: PyObject, forge: PyObject, name: &str, overwrite_unrelated: bool, owner: Option<&str>, preferred_schemes: Option>, ) -> PyResult<(Option, Option, Option>)> { let main_branch = silver_platter::GenericBranch::new(main_branch); let forge = silver_platter::Forge::from(forge); let preferred_schemes = preferred_schemes .as_ref() .map(|s| s.iter().map(|s| s.as_ref()).collect::>()); let (b, o, p) = silver_platter::publish::find_existing_proposed( &main_branch, &forge, name, overwrite_unrelated, owner, preferred_schemes.as_deref(), )?; Ok(( b.map(|x| x.to_object(py)), o, p.map(|p| p.into_iter().map(MergeProposal).collect()), )) } #[pyfunction] #[pyo3(signature = (local_branch, main_branch, forge, name, mp_description, resume_branch=None, resume_proposal=None, overwrite_existing=None, labels=None, commit_message=None, title=None, additional_colocated_branches=None, allow_empty=None, reviewers=None, tags=None, owner=None, stop_revision=None, allow_collaboration=None, auto_merge=None))] fn propose_changes( local_branch: PyObject, main_branch: PyObject, forge: &Forge, name: &str, mp_description: &str, resume_branch: Option, resume_proposal: Option<&MergeProposal>, overwrite_existing: Option, labels: Option>, commit_message: Option<&str>, title: Option<&str>, additional_colocated_branches: Option>, allow_empty: Option, reviewers: Option>, tags: Option>, owner: Option<&str>, stop_revision: Option, allow_collaboration: Option, auto_merge: Option, ) -> PyResult<(MergeProposal, bool)> { let resume_branch = resume_branch.map(|b| breezyshim::branch::GenericBranch::new(b)); silver_platter::publish::propose_changes( &breezyshim::branch::GenericBranch::new(local_branch), &breezyshim::branch::GenericBranch::new(main_branch), &forge.0, name, mp_description, resume_branch .as_ref() .map(|b| b as &dyn silver_platter::Branch), resume_proposal.as_ref().map(|p| p.0.clone()), overwrite_existing, labels, commit_message, title, additional_colocated_branches, allow_empty, reviewers, tags, owner, stop_revision.as_ref(), allow_collaboration, auto_merge, ) .map(|(p, b)| (MergeProposal(p), b)) .map_err(Into::into) } #[pyclass] struct PublishResult(silver_platter::publish::PublishResult); #[pymethods] impl PublishResult { #[getter] fn is_new(&self) -> Option { self.0.is_new } #[getter] fn forge(&self, py: Python) -> Option { Some(self.0.forge.to_object(py)) } } #[pyfunction] #[pyo3(signature = (local_branch, main_branch, mode, name, get_proposal_description, resume_branch=None, get_proposal_commit_message=None, get_proposal_title=None, forge=None, allow_create_proposal=None, labels=None, overwrite_existing=None, existing_proposal=None, reviewers=None, tags=None, derived_owner=None, allow_collaboration=None, stop_revision=None, auto_merge=None))] fn publish_changes( local_branch: PyObject, main_branch: PyObject, mode: Mode, name: &str, get_proposal_description: PyObject, resume_branch: Option, get_proposal_commit_message: Option, get_proposal_title: Option, forge: Option<&Forge>, allow_create_proposal: Option, labels: Option>, overwrite_existing: Option, existing_proposal: Option<&MergeProposal>, reviewers: Option>, tags: Option>, derived_owner: Option<&str>, allow_collaboration: Option, stop_revision: Option, auto_merge: Option, ) -> PyResult { let get_proposal_description = |format: silver_platter::proposal::DescriptionFormat, proposal: Option<&silver_platter::MergeProposal>| { Python::with_gil(|py| { let proposal = proposal.map(|mp| MergeProposal(mp.clone())); get_proposal_description .call1(py, (format.to_string(), proposal)) .unwrap() .extract(py) .unwrap() }) }; let get_proposal_commit_message = get_proposal_commit_message.map(|f| { move |proposal: Option<&silver_platter::MergeProposal>| -> Option { Python::with_gil(|py| { let proposal = proposal.map(|mp| MergeProposal(mp.clone())); f.call1(py, (proposal,)).unwrap().extract(py).unwrap() }) } }); let get_proposal_title = get_proposal_title.map(|f| { move |proposal: Option<&silver_platter::MergeProposal>| -> Option { Python::with_gil(|py| { let proposal = proposal.map(|mp| MergeProposal(mp.clone())); f.call1(py, (proposal,)).unwrap().extract(py).unwrap() }) } }); let resume_branch = resume_branch.map(breezyshim::branch::GenericBranch::new); Ok(PublishResult(silver_platter::publish::publish_changes( &breezyshim::branch::GenericBranch::new(local_branch), &breezyshim::branch::GenericBranch::new(main_branch), resume_branch .as_ref() .map(|b| b as &dyn silver_platter::Branch), mode, name, get_proposal_description, get_proposal_commit_message, get_proposal_title, forge.map(|f| &f.0), allow_create_proposal, labels, overwrite_existing, existing_proposal.map(|p| p.0.clone()), reviewers, tags, derived_owner, allow_collaboration, stop_revision.as_ref(), auto_merge, )?)) } #[pyclass] struct DestroyFn(Option std::io::Result<()> + Send>>); #[pymethods] impl DestroyFn { fn __call__(&mut self) -> PyResult<()> { if let Some(f) = self.0.take() { Ok(f()?) } else { Err(PyRuntimeError::new_err("Already called")) } } } /// Run a script before making any changes to a tree. /// /// Args: /// tree: The working tree to operate in /// script: Command to run /// Raises: /// PreCheckFailed: If the pre-check failed #[pyfunction] fn run_pre_check(tree: PyObject, script: &str) -> PyResult<()> { let tree = WorkingTree::from(tree); silver_platter::checks::run_pre_check(tree, script).map_err(|e| match e { silver_platter::checks::PreCheckFailed => PreCheckFailed::new_err(()), }) } /// Run a script after making any changes to a tree. /// /// Args: /// tree: The working tree to operate in /// script: Command to run /// since_revid: The revision to run the script since /// Raises: /// PreCheckFailed: If the pre-check failed #[pyfunction] fn run_post_check(tree: PyObject, script: &str, since_revid: RevisionId) -> PyResult<()> { let tree = WorkingTree::from(tree); silver_platter::checks::run_post_check(tree, script, &since_revid).map_err(|e| match e { silver_platter::checks::PostCheckFailed => PostCheckFailed::new_err(()), }) } #[pyfunction] #[pyo3(signature = (local_branch, target_branch, stop_revision=None))] fn check_proposal_diff( local_branch: PyObject, target_branch: PyObject, stop_revision: Option, ) -> PyResult<()> { let local_branch = breezyshim::branch::GenericBranch::new(local_branch); let target_branch = breezyshim::branch::GenericBranch::new(target_branch); if silver_platter::publish::check_proposal_diff_empty( &local_branch, &target_branch, stop_revision.as_ref(), )? { Err(EmptyMergeProposal::new_err(())) } else { Ok(()) } } #[cfg(feature = "debian")] pub(crate) mod debian { use super::*; use silver_platter::debian::codemod::Error as DebianCodemodError; #[cfg(feature = "debian")] #[pyfunction] pub fn pick_additional_colocated_branches(main_branch: PyObject) -> HashMap { silver_platter::debian::pick_additional_colocated_branches( &breezyshim::branch::GenericBranch::new(main_branch), ) } #[pyclass] pub(crate) struct DebianCommandResult(silver_platter::debian::codemod::CommandResult); #[pymethods] impl DebianCommandResult { #[getter] fn value(&self) -> Option { self.0.value } #[getter] fn description(&self) -> &str { self.0.description.as_str() } #[getter] fn serialized_context(&self) -> Option<&str> { self.0.serialized_context.as_deref() } #[getter] fn tags(&self) -> Vec<(String, Option)> { self.0.tags.clone() } #[getter] fn target_branch_url(&self) -> Option<&str> { self.0.target_branch_url.as_ref().map(|u| u.as_str()) } #[getter] fn old_revision(&self) -> RevisionId { self.0.old_revision.clone() } #[getter] fn new_revision(&self) -> RevisionId { self.0.new_revision.clone() } #[getter] fn context<'a, 'py>(&self, py: Python<'py>) -> Option> where 'py: 'a, { self.0.context.as_ref().map(|c| json_to_py(py, c)) } } #[pyfunction] #[pyo3(signature = (local_tree, script, subpath=None, commit_pending=None, resume_metadata=None, committer=None, extra_env=None, stderr=None, update_changelog=None))] pub(crate) fn debian_script_runner( py: Python, local_tree: PyObject, script: PyObject, subpath: Option, commit_pending: Option, resume_metadata: Option, committer: Option<&str>, extra_env: Option>, stderr: Option, update_changelog: Option, ) -> PyResult { let script = if let Ok(script) = script.extract::>(py) { script } else { vec![ "sh".to_string(), "-c".to_string(), script.extract::(py)?, ] }; silver_platter::debian::codemod::script_runner( &WorkingTree::from(local_tree), script .iter() .map(|s| s.as_str()) .collect::>() .as_slice(), subpath .as_ref() .map_or_else(|| std::path::Path::new(""), |p| p.as_path()), match commit_pending { Some(true) => CommitPending::Yes, Some(false) => CommitPending::No, None => CommitPending::Auto, }, resume_metadata .map(|m| py_to_json(m.bind(py)).unwrap()) .as_ref(), committer, extra_env, match stderr { Some(stderr) => { let fd = stderr .call_method0(py, "fileno")? .extract::(py) .unwrap(); let f = unsafe { std::fs::File::from_raw_fd(fd) }; std::process::Stdio::from(f) } None => std::process::Stdio::inherit(), }, update_changelog, ) .map(|result| DebianCommandResult(result).into_py(py)) .map_err(|err| match err { DebianCodemodError::ScriptMadeNoChanges => { ScriptMadeNoChanges::new_err("Script made no changes") } DebianCodemodError::ExitCode(code) => { ScriptFailed::new_err(format!("Script failed with exit code {}", code)) } DebianCodemodError::ScriptNotFound => ScriptNotFound::new_err("Script not found"), DebianCodemodError::Detailed(df) => { DetailedFailure::new_err(format!("Script failed: {}", df.description.unwrap())) } DebianCodemodError::Json(err) => { ResultFileFormatError::new_err(format!("Result file format error: {}", err)) } DebianCodemodError::Io(err) => err.into(), DebianCodemodError::Other(err) => { PyRuntimeError::new_err(format!("Script failed: {}", err)) } DebianCodemodError::Utf8(err) => err.into(), DebianCodemodError::ChangelogParse(e) => { MissingChangelog::new_err(format!("Failed to parse changelog {}", e)) } DebianCodemodError::MissingChangelog(p) => { MissingChangelog::new_err(format!("Missing changelog entry for {}", p.display())) } }) } #[pyfunction] pub(crate) fn get_maintainer_from_env( env: HashMap, ) -> Option<(String, String)> { debian_changelog::get_maintainer_from_env(|k| env.get(k).map(|s| s.to_string())) } #[pyfunction] pub(crate) fn is_debcargo_package(tree: PyObject, path: &str) -> PyResult { let tree = WorkingTree::from(tree); Ok(silver_platter::debian::is_debcargo_package( &tree, std::path::Path::new(path), )) } #[pyfunction] pub(crate) fn control_files_in_root(tree: PyObject, path: &str) -> PyResult { let tree = WorkingTree::from(tree); Ok(silver_platter::debian::control_files_in_root( &tree, std::path::Path::new(path), )) } #[pyclass] pub(crate) struct ChangelogBehaviour(silver_platter::debian::ChangelogBehaviour); #[pymethods] impl ChangelogBehaviour { #[getter] fn get_update_changelog(&self) -> bool { self.0.update_changelog } #[getter] fn get_explanation(&self) -> String { self.0.explanation.clone() } } #[pyfunction] pub(crate) fn guess_update_changelog( tree: PyObject, debian_path: &str, ) -> Option { let tree = WorkingTree::from(tree); silver_platter::debian::guess_update_changelog(&tree, std::path::Path::new(debian_path)) .map(ChangelogBehaviour) } #[pyfunction] #[pyo3(signature = (tree, subpath, builder=None, result_dir=None))] pub(crate) fn build( tree: PyObject, subpath: PathBuf, builder: Option<&str>, result_dir: Option, ) -> PyResult<()> { let tree = WorkingTree::from(tree); silver_platter::debian::build(&tree, subpath.as_path(), builder, result_dir.as_deref()) } #[pyfunction] pub(crate) fn install_built_package( local_tree: PyObject, subpath: std::path::PathBuf, build_target_dir: std::path::PathBuf, ) -> PyResult<()> { let local_tree = WorkingTree::from(local_tree); silver_platter::debian::install_built_package( &local_tree, subpath.as_path(), build_target_dir.as_path(), ) .unwrap(); Ok(()) } } /// Check whether two branches are conflicted when merged. /// /// Args: /// main_branch: Main branch to merge into /// other_branch: Branch to merge (and use for scratch access, needs write /// access) /// other_revision: Other revision to check /// Returns: /// boolean indicating whether the merge would result in conflicts #[pyfunction] #[pyo3(signature = (main_branch, other_branch, other_revision=None))] fn merge_conflicts( main_branch: PyObject, other_branch: PyObject, other_revision: Option, ) -> PyResult { Ok(silver_platter::utils::merge_conflicts( &breezyshim::branch::GenericBranch::new(main_branch), &breezyshim::branch::GenericBranch::new(other_branch), other_revision.as_ref(), )?) } fn workspace_error_to_py_err(e: silver_platter::workspace::Error) -> PyErr { import_exception!(breezy.errors, UnknownFormat); import_exception!(breezy.errors, PermissionDenied); match e { silver_platter::workspace::Error::BrzError(e) => e.into(), silver_platter::workspace::Error::IOError(e) => e.into(), silver_platter::workspace::Error::Other(e) => PyRuntimeError::new_err((e,)), silver_platter::workspace::Error::PermissionDenied(e) => PermissionDenied::new_err((e,)), silver_platter::workspace::Error::UnknownFormat(format) => { UnknownFormat::new_err((format,)) } } } #[pyclass(subclass)] struct Workspace(silver_platter::workspace::Workspace); #[pymethods] impl Workspace { /// Create a workspace from a URL. /// /// # Arguments /// * `url` - The URL to create the workspace from #[classmethod] fn from_url(_cls: &Bound, url: &str) -> PyResult { Ok(Self( silver_platter::workspace::Workspace::from_url( &url.parse() .map_err(|e| PyValueError::new_err(format!("Invalid URL: {}", e)))?, ) .map_err(workspace_error_to_py_err)?, )) } #[getter] fn path(&self) -> std::path::PathBuf { self.0.path() } #[getter] fn base_revid(&self) -> Option { self.0.base_revid() } #[new] #[pyo3(signature = (main_branch=None, resume_branch=None, cached_branch=None, dir=None, path=None, additional_colocated_branches=None, resume_branch_additional_colocated_branches=None, format=None))] fn new( py: Python, main_branch: Option, resume_branch: Option, cached_branch: Option, dir: Option, path: Option, additional_colocated_branches: Option, resume_branch_additional_colocated_branches: Option, format: Option, ) -> PyResult { let mut builder = silver_platter::workspace::Workspace::builder(); if let Some(main_branch) = main_branch { builder = builder.main_branch(Box::new(breezyshim::branch::GenericBranch::new( main_branch, ))); } if let Some(resume_branch) = resume_branch { builder = builder.resume_branch(Box::new(breezyshim::branch::GenericBranch::new( resume_branch, ))); } if let Some(cached_branch) = cached_branch { builder = builder.cached_branch(Box::new(breezyshim::branch::GenericBranch::new( cached_branch, ))); } if let Some(additional_colocated_branches) = additional_colocated_branches { if let Ok(additional_colocated_branches) = additional_colocated_branches.extract::>(py) { builder = builder.additional_colocated_branches(additional_colocated_branches); } else if let Ok(additional_colocated_branches) = additional_colocated_branches.extract::>(py) { builder = builder.additional_colocated_branches( additional_colocated_branches .into_iter() .map(|x| (x.clone(), x)) .collect(), ); } else { return Err(PyTypeError::new_err( "additional_colocated_branches must be a dict or a list of tuples", )); } } if let Some(resume_branch_additional_colocated_branches) = resume_branch_additional_colocated_branches { if let Ok(resume_branch_additional_colocated_branches) = resume_branch_additional_colocated_branches.extract::>(py) { builder = builder.resume_branch_additional_colocated_branches( resume_branch_additional_colocated_branches, ); } else if let Ok(resume_branch_additional_colocated_branches) = resume_branch_additional_colocated_branches.extract::>(py) { builder = builder.resume_branch_additional_colocated_branches( resume_branch_additional_colocated_branches .into_iter() .map(|x| (x.clone(), x)) .collect(), ); } else { return Err(PyTypeError::new_err( "resume_branch_additional_colocated_branches must be a dict or a list of tuples", )); } } if let Some(path) = path { builder = builder.path(path); } if let Some(dir) = dir { builder = builder.dir(dir); } if let Some(format) = format { if let Ok(format) = format.extract::(py) { builder = builder.format(format.as_str()); } else if format.bind(py).hasattr("get_format_description")? { builder = builder.format(&breezyshim::controldir::ControlDirFormat::from(format)); } else { return Err(PyTypeError::new_err("format must be a string")); } } Ok(Self(builder.build().map_err(workspace_error_to_py_err)?)) } #[getter] fn base_tree(&self, py: Python) -> PyResult { Ok(self.0.base_tree()?.to_object(py)) } #[getter] fn local_tree(&self, py: Python) -> PyObject { self.0.local_tree().to_object(py) } #[getter] fn main_branch(&self, py: Python) -> PyObject { self.0.main_branch().to_object(py) } #[getter] fn resume_branch(&self, py: Python) -> Option { self.0.resume_branch().map(|b| b.to_object(py)) } fn any_branch_changes(&self) -> bool { self.0.any_branch_changes() } fn changes_since_main(&self) -> bool { self.0.changes_since_main() } fn changes_since_base(&self) -> bool { self.0.changes_since_base() } #[getter] fn main_branch_revid(&self) -> RevisionId { self.0.main_branch().unwrap().last_revision() } #[getter] fn refreshed(&self) -> bool { self.0.refreshed() } fn result_branches(&self) -> Vec<(String, Option, Option)> { self.0.changed_branches() } fn __enter__(slf: Bound) -> Bound { slf.clone() } #[pyo3(signature = (_exc_type, _exc_value, _traceback))] fn __exit__( slf: Bound, _exc_type: Option, _exc_value: Option, _traceback: Option, ) -> PyResult { slf.borrow_mut() .0 .destroy() .map_err(workspace_error_to_py_err)?; Ok(false) } #[pyo3(signature = (outf, old_label=None, new_label=None))] fn show_diff( &self, outf: PyObject, old_label: Option<&str>, new_label: Option<&str>, ) -> PyResult<()> { let outf = Box::new(pyo3_filelike::PyBinaryFile::from(outf)); self.0.show_diff(outf, old_label, new_label)?; Ok(()) } } #[pyfunction] #[pyo3(signature = (vcs_type=None))] fn select_preferred_probers(py: Python, vcs_type: Option<&str>) -> Vec { let probers = silver_platter::probers::select_preferred_probers(vcs_type); probers.into_iter().map(|p| p.to_object(py)).collect() } #[pyfunction] #[pyo3(signature = (vcs_type=None))] fn select_probers(py: Python, vcs_type: Option<&str>) -> Vec { let probers = silver_platter::probers::select_probers(vcs_type); probers.into_iter().map(|p| p.to_object(py)).collect() } #[pymodule(name = "silver_platter")] fn _svp_rs(py: Python, m: &Bound) -> PyResult<()> { pyo3_log::init(); m.add_function(wrap_pyfunction!(derived_branch_name, m)?)?; m.add_function(wrap_pyfunction!(script_runner, m)?)?; m.add_function(wrap_pyfunction!(select_preferred_probers, m)?)?; m.add_function(wrap_pyfunction!(select_probers, m)?)?; m.add( "ScriptMadeNoChanges", py.get_type_bound::(), )?; m.add("ScriptFailed", py.get_type_bound::())?; m.add("ScriptNotFound", py.get_type_bound::())?; m.add("DetailedFailure", py.get_type_bound::())?; m.add("MissingChangelog", py.get_type_bound::())?; m.add("NoTargetBranch", py.get_type_bound::())?; m.add( "ResultFileFormatError", py.get_type_bound::(), )?; m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(push_derived_changes, m)?)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(push_result, m)?)?; m.add_function(wrap_pyfunction!(push_changes, m)?)?; m.add_function(wrap_pyfunction!(full_branch_url, m)?)?; m.add_function(wrap_pyfunction!(merge_conflicts, m)?)?; #[cfg(feature = "debian")] { m.add_class::()?; m.add_function(wrap_pyfunction!(debian::get_maintainer_from_env, m)?)?; m.add_function(wrap_pyfunction!(debian::guess_update_changelog, m)?)?; m.add_class::()?; m.add_function(wrap_pyfunction!(debian::debian_script_runner, m)?)?; m.add_function(wrap_pyfunction!(debian::is_debcargo_package, m)?)?; m.add_function(wrap_pyfunction!(debian::control_files_in_root, m)?)?; m.add_function(wrap_pyfunction!(debian::install_built_package, m)?)?; m.add_function(wrap_pyfunction!(debian::build, m)?)?; m.add_function(wrap_pyfunction!( debian::pick_additional_colocated_branches, m )?)?; } m.add_function(wrap_pyfunction!(find_existing_proposed, m)?)?; m.add_function(wrap_pyfunction!(propose_changes, m)?)?; m.add_function(wrap_pyfunction!(publish_changes, m)?)?; m.add_class::()?; m.add( "InsufficientChangesForNewProposal", py.get_type_bound::(), )?; m.add( "UnrelatedBranchExists", py.get_type_bound::(), )?; m.add_function(wrap_pyfunction!(run_pre_check, m)?)?; m.add_function(wrap_pyfunction!(run_post_check, m)?)?; m.add_function(wrap_pyfunction!(check_proposal_diff, m)?)?; m.add("PostCheckFailed", py.get_type_bound::())?; m.add("PreCheckFailed", py.get_type_bound::())?; m.add( "EmptyMergeProposal", py.get_type_bound::(), )?; m.add("MODE_PUSH", "push")?; m.add("MODE_ATTEMPT_PUSH", "attempt-push")?; m.add("MODE_PROPOSE", "propose")?; m.add("MODE_PUSH_DERIVED", "push-derived")?; m.add( "SUPPORTED_MODES", vec!["push", "attempt-push", "propose", "push-derived"], )?; let items = silver_platter::VERSION.split('.').collect::>(); let tuple = items .iter() .map(|i| i.parse::().unwrap()) .collect::>(); m.add("__version__", pyo3::types::PyTuple::new_bound(py, tuple))?; Ok(()) }