Merged with Trevor's -rr branch
authorGianluca Montecchi <gian@grys.it>
Fri, 2 Oct 2009 21:46:24 +0000 (23:46 +0200)
committerGianluca Montecchi <gian@grys.it>
Fri, 2 Oct 2009 21:46:24 +0000 (23:46 +0200)
236 files changed:
.be/bugs/00f26f04-9202-4288-8744-b29abc2342d6/comments/4be73baf-e46b-4acb-a58e-4719e57c550b/values
.be/bugs/09f84059-fc8e-4954-b24d-a2b33ef21bf4/comments/144c238c-75d1-40f1-82c1-647668bcf2bc/values
.be/bugs/09f84059-fc8e-4954-b24d-a2b33ef21bf4/comments/2bb9163c-a2c4-4301-aff5-385f58a14301/values
.be/bugs/09f84059-fc8e-4954-b24d-a2b33ef21bf4/comments/eff20807-07f0-444d-8992-f69ab3f526c5/values
.be/bugs/0cad2ac6-76ef-4a88-abdf-b2e02de76f5c/comments/16ba77d3-dfc9-4732-8d08-0e471f400d85/values
.be/bugs/0cad2ac6-76ef-4a88-abdf-b2e02de76f5c/comments/17a2217e-fc1d-4d7a-a569-4fd2a4a2261e/values
.be/bugs/0cad2ac6-76ef-4a88-abdf-b2e02de76f5c/comments/202e0dc6-61bf-4b17-a8bd-f8a27482cb68/values
.be/bugs/0cad2ac6-76ef-4a88-abdf-b2e02de76f5c/comments/6a0080c4-d684-4c2c-afaa-c15cc43d68ad/values
.be/bugs/0cad2ac6-76ef-4a88-abdf-b2e02de76f5c/comments/7e733393-8ba0-4345-a0e3-4140101d32f0/values
.be/bugs/16fc9496-cdc2-4c6e-9b9f-b8f483b6dedb/comments/489397bd-b987-4a08-9589-c5b71661ebb7/values
.be/bugs/17921fbc-e7f0-4f31-8cdd-598e5ba7237b/comments/6010e186-0260-44e5-8442-8df2269910ce/values
.be/bugs/17921fbc-e7f0-4f31-8cdd-598e5ba7237b/comments/c2b78df3-641a-4d4d-ba94-33b26eda6364/values
.be/bugs/17921fbc-e7f0-4f31-8cdd-598e5ba7237b/comments/c531727a-9d0f-486f-aa0e-d4d2f2236640/values
.be/bugs/2103f60c-36e5-4b05-b57c-8c6fee2d80d4/comments/b8bbd433-9017-4c04-a038-2a7370a3adc7/values
.be/bugs/2103f60c-36e5-4b05-b57c-8c6fee2d80d4/comments/e5db7c9b-de48-4302-905b-9570bb6e7ade/values
.be/bugs/2929814b-2163-45d0-87ba-f7d1ef0a32a9/comments/6d7072de-89b6-4c53-a435-6879c644a0e8/values
.be/bugs/2aa60b34-2c8d-4f41-bb97-a57309523262/comments/f21bec0d-cad0-44d2-a301-bfb11adce313/values
.be/bugs/301724b1-3853-4aff-8f23-44373df7cf1c/comments/0d8af004-8352-4254-b747-d96a40a5d457/values
.be/bugs/31cd490d-a1c2-4ab3-8284-d80395e34dd2/comments/b2a333f7-eda6-42b9-8940-177f61ca7f48/values
.be/bugs/381555eb-f2e3-4ef0-8303-d759c00b390a/comments/9aa88bbd-71d0-44fa-804d-3562171f9539/values
.be/bugs/381555eb-f2e3-4ef0-8303-d759c00b390a/comments/9e33512e-e3cb-42ec-bc99-8e77587d0d3f/values
.be/bugs/381555eb-f2e3-4ef0-8303-d759c00b390a/comments/b76434a3-5cf9-4d2c-820b-64444289c09f/values
.be/bugs/40dac9af-951e-4b98-8779-9ba02c37f8a1/comments/e1ff6c81-37d8-43ee-9dcf-17a89e07556a/values
.be/bugs/496edad5-1484-413a-bc68-4b01274a65eb/comments/8d927822-eff9-42c4-9541-8b784b3f7db2/values
.be/bugs/4a4609c8-1882-47de-9d30-fee410b8a802/comments/0ac3c4cb-90e3-4b67-b6cb-1186d5d66240/values
.be/bugs/4a4609c8-1882-47de-9d30-fee410b8a802/comments/942cd941-583d-4020-99e4-80de7e836129/values
.be/bugs/4ddf1313-bb3c-45d3-8dca-79ed5830d606/comments/3e83dd98-d421-43b6-a78c-5da7aac5f279/values
.be/bugs/4ddf1313-bb3c-45d3-8dca-79ed5830d606/comments/433e2090-55d6-4b13-bc6d-0b509556f21b/values
.be/bugs/4ddf1313-bb3c-45d3-8dca-79ed5830d606/comments/9d56f097-bf5b-4d8a-a83e-7ade8afd2b4c/values
.be/bugs/4ddf1313-bb3c-45d3-8dca-79ed5830d606/comments/a0cbbd2e-a078-41ac-b583-900e9bb2abf3/values
.be/bugs/4ddf1313-bb3c-45d3-8dca-79ed5830d606/comments/b1a772a0-241f-42fc-8209-765162485b0a/values
.be/bugs/4ddf1313-bb3c-45d3-8dca-79ed5830d606/comments/c1b9bc11-71e1-473e-ad9c-cfba0a2533d5/values
.be/bugs/4ddf1313-bb3c-45d3-8dca-79ed5830d606/comments/d74a6a82-6a08-472b-86d8-b1546c4d460f/values
.be/bugs/4ddf1313-bb3c-45d3-8dca-79ed5830d606/comments/f776d667-6959-4cab-b05d-39e07702c04b/values
.be/bugs/4f7a4c3b-31e3-4023-8c9d-e67f627a34f0/comments/a8f35fca-8a15-4833-b568-326f0cc89bfa/values
.be/bugs/508ea95e-7bc6-4b9b-9e36-a3a87014423d/comments/1ba36272-7ae1-4f95-8002-7b45e62e6790/values
.be/bugs/508ea95e-7bc6-4b9b-9e36-a3a87014423d/comments/e173c09a-1b3e-4d8a-a86a-6b8c94a76247/values
.be/bugs/576e804a-8b76-4876-8e9d-d7a72b0aef10/comments/72dab0c4-f04d-4ff0-9319-f55aafaea627/values
.be/bugs/576e804a-8b76-4876-8e9d-d7a72b0aef10/comments/c454aa67-ca30-43e8-9be4-58cbddd01b63/values
.be/bugs/576e804a-8b76-4876-8e9d-d7a72b0aef10/comments/d83a5436-85e3-42c7-9a89-a6d50df9d279/values
.be/bugs/597a7386-643f-4559-8dc4-6871924229b6/comments/8015d736-f3ea-4085-940c-552c01a287ef/values
.be/bugs/597a7386-643f-4559-8dc4-6871924229b6/comments/eff20807-07f0-444d-8992-f69ab3f526c5/values
.be/bugs/68ba7f0c-ca5f-4f49-a508-e39150c07e13/comments/be64734c-d9a8-4f6d-83eb-e9b6c9adc0bf/values
.be/bugs/6eb8141f-b0b1-4d5b-b4e6-d0860d844ada/comments/f2011471-56cb-46e2-813b-1ac336ee7bbc/values
.be/bugs/7ba4bc51-b251-483a-a67a-f1b89c83f6af/comments/db2c18d9-9573-4d68-88a5-ee47ed24b813/values
.be/bugs/7ba4bc51-b251-483a-a67a-f1b89c83f6af/comments/ec16300f-529a-4492-8327-f9a72e4447c2/values
.be/bugs/7bfc591e-584a-476e-8e11-b548f1afcaa6/comments/2f6b71c5-45b3-473f-bd14-a1fe41bafcee/values
.be/bugs/7bfc591e-584a-476e-8e11-b548f1afcaa6/comments/5a6b44f5-9d1d-4e2e-a42c-f5423c43a1dc/values
.be/bugs/7ec2c071-9630-42b0-b08a-9854616f9144/comments/401950a0-a5ff-46f3-afac-a9cfb300f94b/values
.be/bugs/7ec2c071-9630-42b0-b08a-9854616f9144/comments/6010e186-0260-44e5-8442-8df2269910ce/values
.be/bugs/7ec2c071-9630-42b0-b08a-9854616f9144/comments/80780fa9-69f8-438c-8fbf-5a702b3badc1/values
.be/bugs/7ec2c071-9630-42b0-b08a-9854616f9144/comments/bb988ed4-d3d5-4e49-b67e-c7ccb8ae44d3/values
.be/bugs/7ec2c071-9630-42b0-b08a-9854616f9144/comments/c2b78df3-641a-4d4d-ba94-33b26eda6364/values
.be/bugs/7ec2c071-9630-42b0-b08a-9854616f9144/comments/ec133a4e-c9ff-4499-b469-cb0a2ca9a685/values
.be/bugs/7ec2c071-9630-42b0-b08a-9854616f9144/comments/f87fd684-6af1-498d-98d5-f915bcee76a9/values
.be/bugs/8e1bbda4-35b6-4579-849d-117b1596ee99/comments/4d642e39-a8f3-41d8-93da-bea7e05ef9a6/values
.be/bugs/8e1bbda4-35b6-4579-849d-117b1596ee99/comments/bf0c3752-6338-4919-93ba-4c9252945fb1/values
.be/bugs/8e83da06-26f1-4763-a972-dae7e7062233/comments/13e88b64-117b-4f8b-8cba-8f4a9bc394f5/values
.be/bugs/8e83da06-26f1-4763-a972-dae7e7062233/comments/2ae039de-5b0d-4a4f-aa80-6c81d1345367/values
.be/bugs/8e83da06-26f1-4763-a972-dae7e7062233/comments/a492508e-0be7-4403-bbd0-9cdc0a46b06b/values
.be/bugs/8e948522-c6a1-4c97-af93-2cf4090f44b5/comments/3e7144eb-c934-4b62-94b7-7dbfa90ed6ee/values
.be/bugs/8e948522-c6a1-4c97-af93-2cf4090f44b5/comments/7d7e703f-22f2-4c47-86a3-fcc3c8ead576/values
.be/bugs/9a942b1d-a3b5-441d-8aef-b844700e1efa/comments/209e2a60-ddd0-4a71-90ef-e57547ed6d76/values
.be/bugs/9a942b1d-a3b5-441d-8aef-b844700e1efa/comments/37650981-1908-4c39-bae2-48e69c771120/values
.be/bugs/9ce2f015-8ea0-43a5-a03d-fc36f6d202fe/comments/095ade7c-9378-41bd-8137-f2731c6afcac/values
.be/bugs/9ce2f015-8ea0-43a5-a03d-fc36f6d202fe/comments/4be35966-373b-438c-a35a-824f5c7a940a/values
.be/bugs/9ce2f015-8ea0-43a5-a03d-fc36f6d202fe/comments/d81d0df9-e6d9-4fe8-8dbe-989ef2c81f00/values
.be/bugs/a403de79-8f39-41f2-b9ec-15053b175ee2/comments/0fd8ba95-d9ea-49b3-9f5a-b0eb723cdbe1/values
.be/bugs/a403de79-8f39-41f2-b9ec-15053b175ee2/comments/208595bd-35b8-44c2-bf97-fc5ef9e7a58d/values
.be/bugs/a403de79-8f39-41f2-b9ec-15053b175ee2/comments/25c67b0b-1afd-4613-a787-e0f018614966/values
.be/bugs/a4d38ba7-ec28-4096-a4f3-eb8c9790ffb2/comments/3415fbd7-5a7e-4a7f-af30-82f8ce6ca85b/values
.be/bugs/a4d38ba7-ec28-4096-a4f3-eb8c9790ffb2/comments/b0e7165b-7099-45ca-9513-412225f7bd52/values
.be/bugs/ae998b27-a11b-4243-abf6-11841e5b8242/comments/2628eeca-96c6-4933-8484-d55bb1dbf985/values
.be/bugs/ae998b27-a11b-4243-abf6-11841e5b8242/comments/942cd941-583d-4020-99e4-80de7e836129/values
.be/bugs/ae998b27-a11b-4243-abf6-11841e5b8242/comments/ae0f9aea-960c-42b4-82df-943bbbe17d58/values
.be/bugs/b187fbce-fb10-4819-ace2-c8b0b4a45c57/comments/e757d2ae-085a-4539-99be-096386de5352/values
.be/bugs/b8d95763-1825-4e09-bf52-cbd884b916af/comments/ae56365e-7a9c-4cc3-ba67-7addbeeeff49/values
.be/bugs/c45e5ece-63e3-4fd2-b33f-0bfd06820cf4/comments/04d71e10-9e44-4006-ab37-b4cc71647671/values
.be/bugs/c45e5ece-63e3-4fd2-b33f-0bfd06820cf4/comments/1cb7063f-07ce-4a76-98f9-d184e1ee7282/values
.be/bugs/c4ea43d5-4964-49ea-a1eb-2bab2bde8e2e/comments/2ca25dd6-e9d1-4581-bd29-50f2eaa32fe4/values
.be/bugs/c4ea43d5-4964-49ea-a1eb-2bab2bde8e2e/comments/acbecd72-988c-4899-a340-fea370ce15a8/values
.be/bugs/c4ea43d5-4964-49ea-a1eb-2bab2bde8e2e/comments/b3fabbe0-f05d-42a1-9037-e59e628a83e2/values
.be/bugs/c76d7899-d495-4103-9355-012c0a6fece3/comments/22348320-40d3-422c-bdf0-0f6a6bde3fab/values
.be/bugs/c76d7899-d495-4103-9355-012c0a6fece3/comments/354dcfc6-5997-4ffe-b7a0-baa852213539/values
.be/bugs/c76d7899-d495-4103-9355-012c0a6fece3/comments/c129067c-2341-4e7a-92a6-2dcd30d3bbf5/values
.be/bugs/c76d7899-d495-4103-9355-012c0a6fece3/comments/f847c981-873e-41ae-b5ce-83dfe60b9afe/values
.be/bugs/c894f10f-197d-4b22-9c5b-19f394df40d4/comments/208595bd-35b8-44c2-bf97-fc5ef9e7a58d/values
.be/bugs/c894f10f-197d-4b22-9c5b-19f394df40d4/comments/25c67b0b-1afd-4613-a787-e0f018614966/values
.be/bugs/c894f10f-197d-4b22-9c5b-19f394df40d4/comments/7dfdf230-231b-43e0-9b46-58d4d18eded1/values
.be/bugs/cf56e648-3b09-4131-8847-02dff12b4db2/comments/0e5fab2a-66eb-4f7d-979f-b50181f604d4/values
.be/bugs/cf56e648-3b09-4131-8847-02dff12b4db2/comments/f05359f6-1bfc-4aa6-9a6d-673516bc0f94/values
.be/bugs/dac91856-cb6a-4f69-8c03-38ff0b29aab2/comments/1182d8e6-5e87-4d0a-b271-c298c36bbc21/values
.be/bugs/dac91856-cb6a-4f69-8c03-38ff0b29aab2/comments/8097468f-87a9-4d84-ac20-1772393bb54d/values
.be/bugs/e2f6514c-5f9f-4734-a537-daf3fbe7e9a0/comments/bcd6e5d4-8d03-43ad-a10d-17619735d077/values
.be/bugs/e2f6514c-5f9f-4734-a537-daf3fbe7e9a0/comments/e5decfc6-050b-4283-8776-977bf85b2c99/values
.be/bugs/f70dd5df-805b-49f3-a9ce-12e0fae63365/comments/24903c62-f441-496e-9dcf-17e7a581df33/values
.be/bugs/f7ccd916-b5c7-4890-a2e3-8c8ace17ae3a/comments/028d2e8d-5b0f-4c43-a913-35a1709b2276/values
.be/bugs/f7ccd916-b5c7-4890-a2e3-8c8ace17ae3a/comments/15602c0c-25e4-4c2c-9e24-79bdb90721b1/values
.be/bugs/f7ccd916-b5c7-4890-a2e3-8c8ace17ae3a/comments/3f556a48-c538-4569-8609-3e829b561d78/values
.be/settings
.be/version
Makefile
be
becommands/assign.py
becommands/close.py
becommands/comment.py
becommands/commit.py
becommands/depend.py
becommands/diff.py
becommands/help.py
becommands/html.py
becommands/init.py
becommands/list.py
becommands/merge.py
becommands/new.py
becommands/open.py
becommands/remove.py
becommands/set.py
becommands/severity.py
becommands/show.py
becommands/status.py
becommands/subscribe.py [new file with mode: 0644]
becommands/tag.py
becommands/target.py
interfaces/README [new file with mode: 0644]
interfaces/email/interactive/README [new file with mode: 0644]
interfaces/email/interactive/_procmailrc [new file with mode: 0644]
interfaces/email/interactive/be-handle-mail [new file with mode: 0644]
interfaces/email/interactive/becommands/assign.py [new file with mode: 0644]
interfaces/email/interactive/becommands/close.py [new file with mode: 0644]
interfaces/email/interactive/becommands/comment.py [new file with mode: 0644]
interfaces/email/interactive/becommands/commit.py [new file with mode: 0644]
interfaces/email/interactive/becommands/depend.py [new file with mode: 0644]
interfaces/email/interactive/becommands/diff.py [new file with mode: 0644]
interfaces/email/interactive/becommands/help.py [new file with mode: 0644]
interfaces/email/interactive/becommands/html.py [new file with mode: 0644]
interfaces/email/interactive/becommands/init.py [new file with mode: 0644]
interfaces/email/interactive/becommands/list.py [new file with mode: 0644]
interfaces/email/interactive/becommands/merge.py [new file with mode: 0644]
interfaces/email/interactive/becommands/new.py [new file with mode: 0644]
interfaces/email/interactive/becommands/open.py [new file with mode: 0644]
interfaces/email/interactive/becommands/remove.py [new file with mode: 0644]
interfaces/email/interactive/becommands/set.py [new file with mode: 0644]
interfaces/email/interactive/becommands/severity.py [new file with mode: 0644]
interfaces/email/interactive/becommands/show.py [new file with mode: 0644]
interfaces/email/interactive/becommands/status.py [new file with mode: 0644]
interfaces/email/interactive/becommands/subscribe.py [new file with mode: 0644]
interfaces/email/interactive/becommands/tag.py [new file with mode: 0644]
interfaces/email/interactive/becommands/target.py [new file with mode: 0644]
interfaces/email/interactive/examples/comment [new file with mode: 0644]
interfaces/email/interactive/examples/failing_multiples [new file with mode: 0644]
interfaces/email/interactive/examples/invalid_command [new file with mode: 0644]
interfaces/email/interactive/examples/invalid_subject [new file with mode: 0644]
interfaces/email/interactive/examples/list [new file with mode: 0644]
interfaces/email/interactive/examples/missing_command [new file with mode: 0644]
interfaces/email/interactive/examples/multiple_commands [new file with mode: 0644]
interfaces/email/interactive/examples/new [new file with mode: 0644]
interfaces/email/interactive/examples/new_with_comment [new file with mode: 0644]
interfaces/email/interactive/examples/show [new file with mode: 0644]
interfaces/email/interactive/examples/unicode [new file with mode: 0644]
interfaces/email/interactive/libbe/arch.py [new file with mode: 0644]
interfaces/email/interactive/libbe/beuuid.py [new file with mode: 0644]
interfaces/email/interactive/libbe/bug.py [new file with mode: 0644]
interfaces/email/interactive/libbe/bugdir.py [new file with mode: 0644]
interfaces/email/interactive/libbe/bzr.py [new file with mode: 0644]
interfaces/email/interactive/libbe/cmdutil.py [new file with mode: 0644]
interfaces/email/interactive/libbe/comment.py [new file with mode: 0644]
interfaces/email/interactive/libbe/config.py [new file with mode: 0644]
interfaces/email/interactive/libbe/darcs.py [new file with mode: 0644]
interfaces/email/interactive/libbe/diff.py [new file with mode: 0644]
interfaces/email/interactive/libbe/editor.py [new file with mode: 0644]
interfaces/email/interactive/libbe/encoding.py [new file with mode: 0644]
interfaces/email/interactive/libbe/git.py [new file with mode: 0644]
interfaces/email/interactive/libbe/hg.py [new file with mode: 0644]
interfaces/email/interactive/libbe/mapfile.py [new file with mode: 0644]
interfaces/email/interactive/libbe/plugin.py [new file with mode: 0644]
interfaces/email/interactive/libbe/properties.py [new file with mode: 0644]
interfaces/email/interactive/libbe/settings_object.py [new file with mode: 0644]
interfaces/email/interactive/libbe/tree.py [new file with mode: 0644]
interfaces/email/interactive/libbe/upgrade.py [new file with mode: 0644]
interfaces/email/interactive/libbe/utility.py [new file with mode: 0644]
interfaces/email/interactive/libbe/vcs.py [new file with mode: 0644]
interfaces/email/interactive/libbe/version.py [new file with mode: 0644]
interfaces/email/interactive/send_pgp_mime.py [new file with mode: 0644]
interfaces/web/Bugs-Everywhere-Web/beweb/controllers.py
interfaces/web/Bugs-Everywhere-Web/libbe/arch.py
interfaces/web/Bugs-Everywhere-Web/libbe/beuuid.py
interfaces/web/Bugs-Everywhere-Web/libbe/bug.py
interfaces/web/Bugs-Everywhere-Web/libbe/bugdir.py
interfaces/web/Bugs-Everywhere-Web/libbe/bzr.py
interfaces/web/Bugs-Everywhere-Web/libbe/cmdutil.py
interfaces/web/Bugs-Everywhere-Web/libbe/comment.py
interfaces/web/Bugs-Everywhere-Web/libbe/config.py
interfaces/web/Bugs-Everywhere-Web/libbe/darcs.py
interfaces/web/Bugs-Everywhere-Web/libbe/diff.py
interfaces/web/Bugs-Everywhere-Web/libbe/editor.py
interfaces/web/Bugs-Everywhere-Web/libbe/encoding.py
interfaces/web/Bugs-Everywhere-Web/libbe/git.py
interfaces/web/Bugs-Everywhere-Web/libbe/hg.py
interfaces/web/Bugs-Everywhere-Web/libbe/mapfile.py
interfaces/web/Bugs-Everywhere-Web/libbe/plugin.py
interfaces/web/Bugs-Everywhere-Web/libbe/properties.py
interfaces/web/Bugs-Everywhere-Web/libbe/rcs.py
interfaces/web/Bugs-Everywhere-Web/libbe/settings_object.py
interfaces/web/Bugs-Everywhere-Web/libbe/tree.py
interfaces/web/Bugs-Everywhere-Web/libbe/upgrade.py [new file with mode: 0644]
interfaces/web/Bugs-Everywhere-Web/libbe/utility.py
interfaces/web/Bugs-Everywhere-Web/libbe/vcs.py [new file with mode: 0644]
interfaces/web/Bugs-Everywhere-Web/libbe/version.py [new file with mode: 0644]
interfaces/xml/be-mbox-to-xml
interfaces/xml/be-xml-to-mbox
libbe/arch.py
libbe/beuuid.py
libbe/bug.py
libbe/bugdir.py
libbe/bzr.py
libbe/cmdutil.py
libbe/comment.py
libbe/config.py
libbe/darcs.py
libbe/diff.py
libbe/editor.py
libbe/encoding.py
libbe/git.py
libbe/hg.py
libbe/mapfile.py
libbe/plugin.py
libbe/properties.py
libbe/rcs.py
libbe/settings_object.py
libbe/tree.py
libbe/upgrade.py [new file with mode: 0644]
libbe/utility.py
libbe/vcs.py [new file with mode: 0644]
libbe/version.py [new file with mode: 0644]
update_copyright.sh

index a68a21d2206679d9bfc9a8dbf0442afa55370500..02ee55918e1989026c1002a51fa9796965c43a89 100644 (file)
@@ -1,21 +1,8 @@
+Author: benf
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Fri, 18 Apr 2008 11:21:03 +0000
-
-
-
-
-
-
-From=benf
+Content-type: text/plain
 
 
+Date: Fri, 18 Apr 2008 11:21:03 +0000
 
index 5ed19bf62cdd67e3563db03af61b4a6be8fcc332..34b65140a4fcc58d6c575b0e2213d2fc14907f51 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 04 Dec 2008 13:35:41 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Thu, 04 Dec 2008 13:35:41 +0000
 
index 58e8ffa6a47abf076bd04cb9fd9784e6a2806687..66a9f19b7fca7fc9da87f302360d209574d7b4ef 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/rst
-
-
-
-
-
-
-Date=Thu, 06 Apr 2006 16:47:25 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/rst
 
 
+Date: Thu, 06 Apr 2006 16:47:25 +0000
 
index 2a1c84d58c6efa845bfc898f3f5de6da4deeac46..3f1fb155c6edafb002533f3935ada0c651079ed8 100644 (file)
@@ -1,28 +1,11 @@
+Author: abentley
 
 
+Content-type: text/restructured
 
-Content-type=text/restructured
 
+Date: Thu, 06 Apr 2006 16:54:57 +0000
 
 
-
-
-
-Date=Thu, 06 Apr 2006 16:54:57 +0000
-
-
-
-
-
-
-From=abentley
-
-
-
-
-
-
-In-reply-to=144c238c-75d1-40f1-82c1-647668bcf2bc
-
-
+In-reply-to: 144c238c-75d1-40f1-82c1-647668bcf2bc
 
index d55baa76c80bd46aea46a738669ec4c7b6150195..8dc0882f0f5af38628db56a23c71b0ba63ba3c72 100644 (file)
@@ -1,21 +1,8 @@
+Author: hubert
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Mon, 23 Jun 2008 05:02:22 +0000
-
-
-
-
-
-
-From=hubert
+Content-type: text/plain
 
 
+Date: Mon, 23 Jun 2008 05:02:22 +0000
 
index 1350ffb35759b6b5dd0892b43284719a59b706bc..176ae7f51a823bbf4b1478f9aecb32d8f0c19c31 100644 (file)
@@ -1,21 +1,8 @@
+Author: hubert
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Tue, 24 Jun 2008 02:45:18 +0000
-
-
-
-
-
-
-From=hubert
+Content-type: text/plain
 
 
+Date: Tue, 24 Jun 2008 02:45:18 +0000
 
index 67b182a15986e5616b90e9571c52c1e623ef2f8b..777a3f87879851605dc3a7f7a9acf1431e26703a 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Sun, 16 Nov 2008 20:36:20 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Sun, 16 Nov 2008 20:36:20 +0000
 
index 4a2e1088a306f6e2f98de84d0bae0f161cdc3664..461a5abc2677ebff32e01344dfb4df05c009f510 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 13 Nov 2008 19:31:04 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Thu, 13 Nov 2008 19:31:04 +0000
 
index cbf7142991ef812d407c7d2ead0f4912d88f7c77..e550f5cefa76df756551c31a22d696116fedc565 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 13 Nov 2008 20:18:02 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Thu, 13 Nov 2008 20:18:02 +0000
 
index dae549ffb50db1724fe6536cec01d8baea03b2f6..a346a7c3252806641eaa4fd52cf6f1836dac7b8f 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 17:16:11 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 17:16:11 +0000
 
index b778afbd96e41d316c47fed183015d340db16260..b82fbcbe805c476ab8ab4e4afb494d5daece89e6 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: abentley
 
 
-Date: Mon, 17 Apr 2006 20:59:15 +0000
+Content-type: text/plain
 
 
-From: abentley
+Date: Mon, 17 Apr 2006 20:59:15 +0000
 
index f6daae2268a40c68eda7630698874d1e41dc3478..42836a58f4f939d9b44bdb63f41fd36a2e4c0443 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 21:29:13 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 21:29:13 +0000
 
index 2425f4fe37f5523e8c92df9c78e267a3fa53eb8f..ac20caeb0d6379e15246d8b6e28e7188c7f7bd7c 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 21:29:33 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 21:29:33 +0000
 
index 74ffa839a055bc6b65f8827354f62fd6ae7a9275..01296d8cc6cfedf72d2aac0272b312bc24643cf9 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Sat, 01 Apr 2006 18:32:47 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Sat, 01 Apr 2006 18:32:47 +0000
 
index 6c7fb636d9b218dc9d5f3407a19939f99619b7ce..31bcacb94462089042d17d7677cd7a2f3f6020aa 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Fri, 14 Nov 2008 05:00:43 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Fri, 14 Nov 2008 05:00:43 +0000
 
index fe5568e059c0200eda76a879bcc6db648fd9a5a1..140289271eb6e211ea90fddc999361ef78c91317 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Wed, 04 Jan 2006 21:03:54 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Wed, 04 Jan 2006 21:03:54 +0000
 
index ad389a7ebddeddce31259b63bb1edfbb3160921b..4c495f7f0d812723a9f93835cd4b249512d8b742 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 17:21:08 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 17:21:08 +0000
 
index 6e9546e49cc3361c1b7797857f7528a2b44003a0..0eaf9c9b59db5cf6172973436d0e8906e0907818 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 17:40:08 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 17:40:08 +0000
 
index c499bfe9f22905dca3087fdc5791f50839aca5ce..ab313b96b01e2aac7d80209cc3ba5fcd195b7e31 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 13 Nov 2008 17:27:17 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Thu, 13 Nov 2008 17:27:17 +0000
 
index 1f7615e740a27f780e2296d1de450b060b9ab56d..ab2a5672fb6195ba7ecfed336d58c20687976544 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 04 Dec 2008 13:44:33 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Thu, 04 Dec 2008 13:44:33 +0000
 
index f88e71f355e50b31e8206fc66ee8399db0c9a297..f692e19e984a940caa920a508b4332ee3c3cc9b2 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Tue, 17 May 2005 13:42:52 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Tue, 17 May 2005 13:42:52 +0000
 
index e9394384fdf575030806287272f79a8255ad61b6..a0c9a341d97a0b0e31c59903fd67c6b062ee9f43 100644 (file)
@@ -1,28 +1,11 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
+Content-type: text/plain
 
-Content-type=text/plain
 
+Date: Thu, 04 Dec 2008 13:46:32 +0000
 
 
-
-
-
-Date=Thu, 04 Dec 2008 13:46:32 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
-
-
-
-
-
-
-In-reply-to=9e33512e-e3cb-42ec-bc99-8e77587d0d3f
-
-
+In-reply-to: 9e33512e-e3cb-42ec-bc99-8e77587d0d3f
 
index b5100d002272ea1abb03a36f2306534ebe09c68e..e434e1e01484721ca04c3a9aef1a15cc31d20908 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 13 Nov 2008 15:58:18 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Thu, 13 Nov 2008 15:58:18 +0000
 
index b19c06547c4b5caec3d38ab716bcd9865880e3e9..e9bbdac48951ea7478bf09b2b21ccb2957e1ff21 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Sat, 22 Nov 2008 18:53:20 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Sat, 22 Nov 2008 18:53:20 +0000
 
index 667dc943e97f4136953d48622695e49c28fd0636..63842d105ed03b9d87f18da86e7472653f253188 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 17:05:50 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 17:05:50 +0000
 
index 225f59e7589fe308dd1eafcfff9e7050c005f66b..6a4005cc349cbed5b1b8da9403fd2d0cc850f976 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 15:42:07 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 15:42:07 +0000
 
index 062b638bbaf10db2b9a26e6292c46b0db693c64a..a5cf8148060cde0c1ac749dfce616a5fb2dcfd0b 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: Gianluca Montecchi <gian@grys.it>
 
 
-Date: Tue, 21 Jul 2009 21:33:37 +0000
+Content-type: text/plain
 
 
-From: Gianluca Montecchi <gian@grys.it>
+Date: Tue, 21 Jul 2009 21:33:37 +0000
 
 
 In-reply-to: f776d667-6959-4cab-b05d-39e07702c04b
index f335cedb2e3179b56fcb39ed961b66a10bc4e3ef..0fc877bd810a4de0bc249b14fc242ad4813c8fa5 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: Gianluca Montecchi <gian@grys.it>
 
 
-Date: Tue, 21 Jul 2009 21:33:29 +0000
+Content-type: text/plain
 
 
-From: Gianluca Montecchi <gian@grys.it>
+Date: Tue, 21 Jul 2009 21:33:29 +0000
 
 
 In-reply-to: a0cbbd2e-a078-41ac-b583-900e9bb2abf3
index b2249ff2155ff02696eb0265bc180c23cc9653fc..5423bb5e36455a2f13df7bf47ae61e2e67e9e570 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: Gianluca Montecchi <gian@grys.it>
 
 
-Date: Tue, 04 Aug 2009 19:48:58 +0000
+Content-type: text/plain
 
 
-From: Gianluca Montecchi <gian@grys.it>
+Date: Tue, 04 Aug 2009 19:48:58 +0000
 
 
 In-reply-to: 433e2090-55d6-4b13-bc6d-0b509556f21b
index 06691af7956e480454e89c3014b562a4d6517f80..cc2c5ba828836828cf8169c522d536e74ed18073 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: Gianluca Montecchi <gian@grys.it>
 
 
-Date: Tue, 21 Jul 2009 21:31:21 +0000
+Content-type: text/plain
 
 
-From: Gianluca Montecchi <gian@grys.it>
+Date: Tue, 21 Jul 2009 21:31:21 +0000
 
 
 In-reply-to: f776d667-6959-4cab-b05d-39e07702c04b
index b6abe1fdb516804ad20d70d5c486fbe70e42f32d..dc8f664f12d582bb0e1f2f17cec5927bcc00c0c4 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: Gianluca Montecchi <gian@grys.it>
 
 
-Date: Wed, 22 Jul 2009 21:48:23 +0000
+Content-type: text/plain
 
 
-From: Gianluca Montecchi <gian@grys.it>
+Date: Wed, 22 Jul 2009 21:48:23 +0000
 
index 2b282c0f68aa68227d43459d9628a6df97e387c5..785b9b005fe8306ae3b30eb950247ed89d2245dd 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: Gianluca Montecchi <gian@grys.it>
 
 
-Date: Mon, 27 Jul 2009 20:08:02 +0000
+Content-type: text/plain
 
 
-From: Gianluca Montecchi <gian@grys.it>
+Date: Mon, 27 Jul 2009 20:08:02 +0000
 
 
 In-reply-to: d74a6a82-6a08-472b-86d8-b1546c4d460f
index e26b88ef22a170b5ccdd689f108436daaac870b9..4d949f3c3f8b4f42198ac95afc0ce536a269e66a 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: Gianluca Montecchi <gian@grys.it>
 
 
-Date: Wed, 22 Jul 2009 20:05:15 +0000
+Content-type: text/plain
 
 
-From: Gianluca Montecchi <gian@grys.it>
+Date: Wed, 22 Jul 2009 20:05:15 +0000
 
index dd1fd4b43b9a3bfa4ef0ae1d2e6d8bf69bda47b3..9872298b2c5fe058a2654204bc87996d16042edf 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: Gianluca Montecchi <gian@grys.it>
 
 
-Date: Mon, 20 Jul 2009 21:54:57 +0000
+Content-type: text/plain
 
 
-From: Gianluca Montecchi <gian@grys.it>
+Date: Mon, 20 Jul 2009 21:54:57 +0000
 
index 1f7615e740a27f780e2296d1de450b060b9ab56d..ab2a5672fb6195ba7ecfed336d58c20687976544 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 04 Dec 2008 13:44:33 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Thu, 04 Dec 2008 13:44:33 +0000
 
index c8719a0eb06f2ebc302d9d8140788a0f006e32ac..3ea62d241ccf59d1a827cac3de51d1444862a6ec 100644 (file)
@@ -1,28 +1,11 @@
+Author: abentley
 
 
+Content-type: text/plain
 
-Content-type=text/plain
 
+Date: Mon, 16 Jul 2007 15:23:47 +0000
 
 
-
-
-
-Date=Mon, 16 Jul 2007 15:23:47 +0000
-
-
-
-
-
-
-From=abentley
-
-
-
-
-
-
-In-reply-to=e173c09a-1b3e-4d8a-a86a-6b8c94a76247
-
-
+In-reply-to: e173c09a-1b3e-4d8a-a86a-6b8c94a76247
 
index 96cc18c1298d17afab6bb4bf092edf8161698212..3292da38e50feabbba13734267c86b0e0b9d7943 100644 (file)
@@ -1,21 +1,8 @@
+Author: jelmer
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Sun, 15 Jul 2007 13:34:52 +0000
-
-
-
-
-
-
-From=jelmer
+Content-type: text/plain
 
 
+Date: Sun, 15 Jul 2007 13:34:52 +0000
 
index 3a2ebfb0dbe89ab4aac63ba32002f3348252cfa1..f4608402aaa398948d44b4cfeca95bc3292e4ee8 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/html
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:05:00 +0000
+Content-type: text/html
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:05:00 +0000
 
 
 In-reply-to: c454aa67-ca30-43e8-9be4-58cbddd01b63
index 1456cca600e71f7e0d862fa320e6ca313a6dd396..0c7cd6c149039259e0cfc03fb7140b3720df93c0 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:03:27 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:03:27 +0000
 
 
 In-reply-to: d83a5436-85e3-42c7-9a89-a6d50df9d279
index 4aea60af7ecd18f7092299a05f491bb6376c9675..7c8dc1cd5304a1eed733eb4703791a3349634cfe 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Fri, 19 Jun 2009 20:22:19 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Fri, 19 Jun 2009 20:22:19 +0000
 
index d7e4f4915bd92545eec33febe0199db9b35fd5a5..929dce64f3f083c5567c91c10338f63c65fc6c08 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 04 Dec 2008 13:35:42 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Thu, 04 Dec 2008 13:35:42 +0000
 
index 9121d125851a105de3cae9b8d177da37c8626ba1..54570b2880839fc2a0ffb98db1d8440be61bd747 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/restructured
-
-
-
-
-
-
-Date=Thu, 06 Apr 2006 16:54:57 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/restructured
 
 
+Date: Thu, 06 Apr 2006 16:54:57 +0000
 
index 84da235822ec7e0fe212937948ffd622b38f9006..9f4fd8ce62c7cbf791ba2690efbe008ea7edab96 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 17:29:30 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 17:29:30 +0000
 
index ba9e33e71d2da5c52f1a00d37e1cdf3aefca5114..a1e25ea9c1a2eee443ec0ca1b9ee8988112eef33 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Fri, 27 Jan 2006 14:30:26 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Fri, 27 Jan 2006 14:30:26 +0000
 
index 4cb1f3504655a6c9b6a6700884c7d3b1ae8b6995..ead68e149d7369138c2eca1f8091da1afcebffe6 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 24 Mar 2005 17:04:47 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Thu, 24 Mar 2005 17:04:47 +0000
 
index 51af41dfa632bf41d1b5a8b0ddb468824d1b8e11..90beac0652a487f72305de6688ececf732eab6ed 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 24 Mar 2005 13:05:13 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Thu, 24 Mar 2005 13:05:13 +0000
 
index ada2348f071cbd9e6f1e8f2b7ffbdde9e14b07b8..2d5059ce0a95dff830e51ac6794bf3e4f32bf9df 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Mon, 24 Nov 2008 13:08:07 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Mon, 24 Nov 2008 13:08:07 +0000
 
index 2bde2a377d9603788bfd1e60abb3d979809c73ba..dcdd529c6149b5c008cf43488e219632f6f49d65 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Wed, 21 Dec 2005 21:53:47 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Wed, 21 Dec 2005 21:53:47 +0000
 
index 4d945d0e236630c805bf7159aa48ef744ab89034..c054670b774d066527579eb8db149680eacc85a7 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:39:39 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:39:39 +0000
 
index 98b79851d55fbfa9977d387815f21c27cf860eca..32e49e719c6a5d2df9ca61a2d36909c1d86669a5 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: abentley
 
 
-Date: Mon, 17 Apr 2006 20:59:15 +0000
+Content-type: text/plain
 
 
-From: abentley
+Date: Mon, 17 Apr 2006 20:59:15 +0000
 
 
 In-reply-to: f87fd684-6af1-498d-98d5-f915bcee76a9
index cd3e2bfcb2be68e4baac7265b3e4d8d12fce9cde..d2f0f5c5a71f6b34cb2716b2564fe6c8bf6d055c 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 25 Jun 2009 12:39:26 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 25 Jun 2009 12:39:26 +0000
 
index 3754f284d518805a7ed33784435ae512de2069e3..8874446b90b9c2827f1007aa42bc098f01bf2e1b 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:42:12 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:42:12 +0000
 
 
 In-reply-to: ec133a4e-c9ff-4499-b469-cb0a2ca9a685
index 11fb7b0bda3bab65d1847a6d622b8560ad32d7be..fe86bd492e20073e18bea8f86298cac7d65f68d4 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 21:29:13 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 21:29:13 +0000
 
 
 In-reply-to: f87fd684-6af1-498d-98d5-f915bcee76a9
index 9078dd64e14cb395e71a02ae1d628f5da2d830ef..c85b16f1e0234c53c56a789255c2e8b6fe378c9d 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:40:54 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:40:54 +0000
 
 
 In-reply-to: 401950a0-a5ff-46f3-afac-a9cfb300f94b
index 0465a85c2b32ea7bd43004b77b8491b653ad09da..2b6307e0361db7f8917ea2a042db7dcc31731d6f 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 21:29:32 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 21:29:32 +0000
 
index 39df7ff6fc049bcf94f0b4bc8f94d26d7b1e7eaf..924ca864c9e8cd36c7a68c94d6f1cb1ae9c97eb0 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 27 Nov 2008 14:26:18 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Thu, 27 Nov 2008 14:26:18 +0000
 
index ea73789f3a3bc589945df24ccf3e30296ac7a9ee..fb952c2dde71fc194f578dc0231f6d04be82f4ac 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 27 Nov 2008 13:43:47 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Thu, 27 Nov 2008 13:43:47 +0000
 
index f109f3e105075b6e0d77e5093059b579fbf2ec53..3453fd90412fcd0df1e9c51f25a03e60fa1ac81a 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Fri, 21 Nov 2008 18:41:47 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Fri, 21 Nov 2008 18:41:47 +0000
 
index e0e3783c50e8c0eede30b6f90006fb1f63ce1b7c..2a76a4e4b3c7e251d4476a1a29f19d7fd2577356 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Fri, 21 Nov 2008 19:12:42 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Fri, 21 Nov 2008 19:12:42 +0000
 
index e5498c95a9dc1ebff4522e12ec8af9f1f638c8f2..d63f4e1749279a1c88b94510cef6028ad6414c42 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Fri, 21 Nov 2008 19:01:19 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Fri, 21 Nov 2008 19:01:19 +0000
 
index cebf3cf73fe8d027349ff858a4c8c638bf37fcde..dea080889ed4d4290d6883af8ce07b08c5d46473 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 19:46:45 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 19:46:45 +0000
 
index 560e158497c7ea3550b72857b58b3faef8d30492..ca6c353ae21c2d1f2904e9a22e32b8f4344e82dd 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 24 Nov 2008 13:10:38 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 24 Nov 2008 13:10:38 +0000
 
index 8270e8e492cc8f30069fa555ae4038303bb2c0e8..901c32f6dc4c9c314dbe1d097caf7d2b5981f24f 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 18:05:38 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 18:05:38 +0000
 
index eb5d3c04307cd7f83758c98080737a3aeb0ec08d..4031ab2e2cfd3803949e1ac8f2f22d1555e6c291 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: abentley
 
 
-Date: Fri, 31 Mar 2006 22:15:09 +0000
+Content-type: text/plain
 
 
-From: abentley
+Date: Fri, 31 Mar 2006 22:15:09 +0000
 
index 7ba64d00fcefd202552a2804fcef81f0beee3f0c..98f869bdd665e86d57f2573a131a0afa0f104be0 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 18:40:43 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 18:40:43 +0000
 
index 47ac98370d96c26c1a38e523131a91308297474c..cebbded589abda966d75424e3d9c66ff8e3f740e 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 21:12:00 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 21:12:00 +0000
 
 
 In-reply-to: d81d0df9-e6d9-4fe8-8dbe-989ef2c81f00
index 2355aa51593fb3ddfa02c1c71392d408d869b489..c2f0da85030f36b9d77bc64aaa0da4a7d2a2a55d 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 19:43:21 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 19:43:21 +0000
 
index b0ecc8fcbfad95530ac345066b0b4b3fc8af09e7..1bd47bfcb48f61fcaeb86d96c91fc083d2a02d6c 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Tue, 25 Nov 2008 02:24:04 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Tue, 25 Nov 2008 02:24:04 +0000
 
index a93e6493c72c624632ef0c69923f712ad5c69709..7a04fc3a9d559e5ca6de3210af14919fede07660 100644 (file)
@@ -1,28 +1,11 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
+Content-type: text/plain
 
-Content-type=text/plain
 
+Date: Sat, 22 Nov 2008 21:43:29 +0000
 
 
-
-
-
-Date=Sat, 22 Nov 2008 21:43:29 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
-
-
-
-
-
-
-In-reply-to=0fd8ba95-d9ea-49b3-9f5a-b0eb723cdbe1
-
-
+In-reply-to: 0fd8ba95-d9ea-49b3-9f5a-b0eb723cdbe1
 
index 35b6806b4f953454b74539f258f1f9350c58cd31..0c872737975b83c904886be81ba868f6470e676a 100644 (file)
@@ -1,28 +1,11 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
+Content-type: text/plain
 
-Content-type=text/plain
 
+Date: Sun, 23 Nov 2008 12:37:57 +0000
 
 
-
-
-
-Date=Sun, 23 Nov 2008 12:37:57 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
-
-
-
-
-
-
-In-reply-to=0fd8ba95-d9ea-49b3-9f5a-b0eb723cdbe1
-
-
+In-reply-to: 0fd8ba95-d9ea-49b3-9f5a-b0eb723cdbe1
 
index 6df7a97a2759569020d40b341fa3299be8a25bfa..8086b48e110cd3fea973a6d99dfa78aaaccfb7fb 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Mon, 24 Nov 2008 13:05:07 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Mon, 24 Nov 2008 13:05:07 +0000
 
index 8e0fad6b315294294ae2d33d66a88d95d74cc15f..e94fece188a0ba204130b02aa5248a76163b3dea 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Mon, 10 Apr 2006 23:23:25 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Mon, 10 Apr 2006 23:23:25 +0000
 
index afd88e5a266bd2d3c560e900356450ec7c4dc0e3..8fcd9d4f0c20975d7dc42a70cc5728a5eceb6e41 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 17:05:49 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 17:05:49 +0000
 
index 366395d4cacf7ebb0b26b5d698a9a54da18b7f82..3033bc10991fc72490e94d3dae2362035f0ddbe6 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 15:42:07 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 15:42:07 +0000
 
 
 In-reply-to: 2628eeca-96c6-4933-8484-d55bb1dbf985
index 80e328bb70c174d25fb497c5525f4003463adce7..4a24d7e133c42161dbe8d975975ef38ad124b000 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 17:07:25 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 17:07:25 +0000
 
index b7b289e18f11620ca0988edf5b46d2585fc5b29c..251453e7064953bbad3e3e9e59e9bf06b0641b2e 100644 (file)
@@ -1,21 +1,8 @@
+Author: benf
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Mon, 21 Apr 2008 03:24:11 +0000
-
-
-
-
-
-
-From=benf
+Content-type: text/plain
 
 
+Date: Mon, 21 Apr 2008 03:24:11 +0000
 
index 1ff89fafb7e783b4d32919cb2acb24db4af5a8b9..549a346c517defe5a1b805ef1e410a979aedd3ab 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 04 Dec 2008 13:48:47 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Thu, 04 Dec 2008 13:48:47 +0000
 
index 3a3c77b7976123a141d3539d53667773c7fd9fa1..cdba2a500ac4b61ea1c6b64d696394273faba481 100644 (file)
@@ -1,21 +1,8 @@
+Author: j
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Mon, 14 Apr 2008 16:43:07 +0000
-
-
-
-
-
-
-From=j
+Content-type: text/plain
 
 
+Date: Mon, 14 Apr 2008 16:43:07 +0000
 
index f94558c2aaf34cf624d1baedb155081a19446ad3..6a0464f285226006eb511602aa6c505cd4dc29c0 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Mon, 24 Nov 2008 13:23:43 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Mon, 24 Nov 2008 13:23:43 +0000
 
index 9f2b5588212f8aeaefbe23e6b34cddece194d5cb..ae76653269fc8f226fae288ade5a626eef139048 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 13 Nov 2008 16:35:24 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Thu, 13 Nov 2008 16:35:24 +0000
 
index 9b72e2c1e691ab9c261c6ec72ac4370753cbc46e..cf27de645e92b348730f93804d22246d8a93426c 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Wed, 19 Nov 2008 17:11:51 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Wed, 19 Nov 2008 17:11:51 +0000
 
index c404aa9b2a2a5e91820e846b177fca1f683d8f59..81035120f025f0e6222624988ab5813f261d1982 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 13 Nov 2008 16:38:36 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Thu, 13 Nov 2008 16:38:36 +0000
 
index bfc4ff7bdf949d3e774a529c4e44def4ba6b9a9e..6bc92583c6cd7bb39e7a374cfd6bd234ced1a551 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:12:35 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:12:35 +0000
 
 
 In-reply-to: 354dcfc6-5997-4ffe-b7a0-baa852213539
index bc3434d799259ddb896ba85f24fa425e29beebc3..d000e42034d070810f323208c76b370fd426e542 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:11:02 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:11:02 +0000
 
index 172a87c4ea34daf8a4804d4523cf56d405bb1c07..34d6548a9c1b4c2c73dc58c73e891ddc0cf9ac39 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:20:39 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:20:39 +0000
 
 
 In-reply-to: f847c981-873e-41ae-b5ce-83dfe60b9afe
index 0ecd1435003d772d79084067d962c1ba917798b5..62a3fb7cb3d9437d903f485c0eb4364aba324916 100644 (file)
@@ -1,10 +1,10 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 20:14:26 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 20:14:26 +0000
 
 
 In-reply-to: 22348320-40d3-422c-bdf0-0f6a6bde3fab
index 368afb33f5c08601418f471efe30d50d756a88d4..645e8c9101dc3b13f056124d1011b08ab5fc73fc 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Sat, 22 Nov 2008 21:43:29 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Sat, 22 Nov 2008 21:43:29 +0000
 
index 5953360c4ab02464608de3f3c67ea00a266aaad3..32491b78464a661e3f3f244a0b807d3d5f37efa9 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Sun, 23 Nov 2008 12:37:57 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Sun, 23 Nov 2008 12:37:57 +0000
 
index 82839965c62c2b7aabf3bb908633fd408ff95096..a0c3bd7737cd8aef0d93127cfda48290838ba3c2 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Tue, 25 Nov 2008 02:24:05 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Tue, 25 Nov 2008 02:24:05 +0000
 
index e8c9da642ccf15c0746a0bea1734c63f8b662525..2dea024878f9bb60fc6e8a357bcb49cacbf05341 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Mon, 22 Jun 2009 19:48:44 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Mon, 22 Jun 2009 19:48:44 +0000
 
index e964891864e129f15cc40e9c3be534a0bd5cc446..405abc1327fe3ed54cd358de626b86d415c8767d 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: wking
 
 
-Date: Sat, 15 Nov 2008 23:56:51 +0000
+Content-type: text/plain
 
 
-From: wking
+Date: Sat, 15 Nov 2008 23:56:51 +0000
 
index 92e7e86914133e261fc030ba2ce98710ba53d274..5972d7a68b19d758f01f5fbede1016faaf379f2f 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@example.com>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Wed, 19 Nov 2008 01:12:37 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@example.com>
+Content-type: text/plain
 
 
+Date: Wed, 19 Nov 2008 01:12:37 +0000
 
index 13df02145249164ab92b5416e596499ffc77bf6a..8496f0a4b555775c727225c481c5bdd9cad23b89 100644 (file)
@@ -1,21 +1,8 @@
+Author: wking
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Mon, 17 Nov 2008 15:03:58 +0000
-
-
-
-
-
-
-From=wking
+Content-type: text/plain
 
 
+Date: Mon, 17 Nov 2008 15:03:58 +0000
 
index b2bedbb57bb900d1219a0735c95f4212f3014437..ae0ca8f74f7c6c50060c0b5e6337c26d5034eeda 100644 (file)
@@ -1,28 +1,11 @@
+Author: abentley
 
 
+Content-type: text/plain
 
-Content-type=text/plain
 
+Date: Thu, 14 Sep 2006 18:05:48 +0000
 
 
-
-
-
-Date=Thu, 14 Sep 2006 18:05:48 +0000
-
-
-
-
-
-
-From=abentley
-
-
-
-
-
-
-In-reply-to=e5decfc6-050b-4283-8776-977bf85b2c99
-
-
+In-reply-to: e5decfc6-050b-4283-8776-977bf85b2c99
 
index 9e82a6ec7144793c91c83fcdd6d960e14e0923d5..72d84b70c3ebba3775eca16851e65fea9731f4b8 100644 (file)
@@ -1,21 +1,8 @@
+Author: abentley
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Thu, 14 Sep 2006 18:03:41 +0000
-
-
-
-
-
-
-From=abentley
+Content-type: text/plain
 
 
+Date: Thu, 14 Sep 2006 18:03:41 +0000
 
index 7bf391a4092794d44a85151fdfcfb9f72773a00a..9825ae80b4a192164b982f8fe177c3dbb9836668 100644 (file)
@@ -1,8 +1,8 @@
-Content-type: text/plain
+Author: W. Trevor King <wking@drexel.edu>
 
 
-Date: Thu, 04 Dec 2008 17:20:20 +0000
+Content-type: text/plain
 
 
-From: W. Trevor King <wking@drexel.edu>
+Date: Thu, 04 Dec 2008 17:20:20 +0000
 
index eb5631713afd540f6dffa87232985ed67ab9e774..1e12a5346226251bf5ab477007eb461484705765 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Tue, 25 Nov 2008 19:41:02 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Tue, 25 Nov 2008 19:41:02 +0000
 
index f976972598828b808d900194f691a48454452903..95751fd46488522ab0650d61ffd5823060131983 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Tue, 25 Nov 2008 02:36:16 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Tue, 25 Nov 2008 02:36:16 +0000
 
index bf5085b86e5f5012bf5f7fbc54f5a9dab126daa2..1e4f9c56d9d63cd00a4bb464bce5201fbb5d7a28 100644 (file)
@@ -1,21 +1,8 @@
+Author: W. Trevor King <wking@drexel.edu>
 
 
-
-Content-type=text/plain
-
-
-
-
-
-
-Date=Tue, 25 Nov 2008 03:02:59 +0000
-
-
-
-
-
-
-From=W. Trevor King <wking@drexel.edu>
+Content-type: text/plain
 
 
+Date: Tue, 25 Nov 2008 03:02:59 +0000
 
index a9bd6dd6de2a175ed832fa0305c5867eb1f855ce..2fd475d00ca2a1714c55b00efaf151dfab8e9faf 100644 (file)
@@ -9,5 +9,5 @@ inactive_status:
   - Unknown meaning.  For backwards compatibility with old BE bugs.
 
 
-rcs_name: bzr
+vcs_name: bzr
 
index 990837ef3d7bce7b44d1e9f80bbf995dd32e2346..7bd05c2187707950297403aa026dbec42926b878 100644 (file)
@@ -1 +1 @@
-Bugs Everywhere Tree 1 0
+Bugs Everywhere Directory v1.2
index b1207c2baaa62daed53dddd6b41a7b7831d20754..fe482c33b82f3653fc81579ad3739aa961031fa2 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -57,8 +57,8 @@ build: libbe/_version.py
 .PHONY: install
 install: doc build
        python setup.py install ${INSTALL_OPTIONS}
-       cp -v interfaces/xml/* ${PREFIX}/bin
-       cp -v interfaces/email/catmutt ${PREFIX}/bin
+#cp -v interfaces/xml/* ${PREFIX}/bin
+#cp -v interfaces/email/catmutt ${PREFIX}/bin
 
 \f
 .PHONY: clean
diff --git a/be b/be
index 36deabab5c5025356e8e69febbdc2fea70145170..feacfb4e831d799c457400ca4abeb0c7d45e43d8 100755 (executable)
--- a/be
+++ b/be
@@ -21,7 +21,7 @@
 import os
 import sys
 
-from libbe import cmdutil, _version
+from libbe import cmdutil, version
 
 __doc__ = cmdutil.help()
 
@@ -31,6 +31,8 @@ parser = cmdutil.CmdOptionParser(usage)
 parser.command = "be"
 parser.add_option("--version", action="store_true", dest="version",
                   help="Print version string and exit.")
+parser.add_option("--verbose-version", action="store_true", dest="verbose_version",
+                  help="Print verbose version information and exit.")
 parser.add_option("-d", "--dir", dest="dir", metavar="DIR",
                   help="Run this command from DIR instead of the current directory.")
 
@@ -50,8 +52,8 @@ except cmdutil.GetCompletions, e:
     print '\n'.join(e.completions)
     sys.exit(0)
 
-if options.version == True:
-    print _version.version_info["revision_id"]
+if options.version == True or options.verbose_version == True:
+    print version.version(verbose=options.verbose_version)
     sys.exit(0)
 if options.dir != None:
     os.chdir(options.dir)
index 536bca6d4ffd41fd614363d3f256fdb4b5fcfe04..794f0283a24fd8b2f447750ce1731b744911b51e 100644 (file)
 from libbe import cmdutil, bugdir
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
     >>> bd.bug_from_shortname("a").assigned is None
     True
 
-    >>> execute(["a"], test=True)
+    >>> execute(["a"], manipulate_encodings=False)
     >>> bd._clear_bugs()
     >>> bd.bug_from_shortname("a").assigned == bd.user_id
     True
 
-    >>> execute(["a", "someone"], test=True)
+    >>> execute(["a", "someone"], manipulate_encodings=False)
     >>> bd._clear_bugs()
     >>> print bd.bug_from_shortname("a").assigned
     someone
 
-    >>> execute(["a","none"], test=True)
+    >>> execute(["a","none"], manipulate_encodings=False)
     >>> bd._clear_bugs()
     >>> bd.bug_from_shortname("a").assigned is None
     True
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -53,7 +54,9 @@ def execute(args, test=False):
     if len(args) > 2:
         help()
         raise cmdutil.UsageError("Too many arguments.")
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
     bug = bd.bug_from_shortname(args[0])
     if len(args) == 1:
         bug.assigned = bd.user_id
index 0ba8f502bae41bf94b195e9157ef5d0a44e6073c..0532ed2de463d89e90f90119590ff6e3e525e507 100644 (file)
 from libbe import cmdutil, bugdir
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> from libbe import bugdir
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
     >>> print bd.bug_from_shortname("a").status
     open
-    >>> execute(["a"], test=True)
+    >>> execute(["a"], manipulate_encodings=False)
     >>> bd._clear_bugs()
     >>> print bd.bug_from_shortname("a").status
     closed
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -41,8 +42,9 @@ def execute(args, test=False):
         raise cmdutil.UsageError("Please specify a bug id.")
     if len(args) > 1:
         raise cmdutil.UsageError("Too many arguments.")
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
-    bug = bd.bug_from_shortname(args[0])
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
     bug.status = "closed"
     bd.save()
 
index 55b5913b8c60a166a2e10ead1ba05e3c576f9bf6..9a614b26916ae6e412bbebb852ab20fc473103eb 100644 (file)
@@ -25,20 +25,20 @@ except ImportError: # look for non-core module
     from elementtree import ElementTree
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import time
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
-    >>> execute(["a", "This is a comment about a"], test=True)
+    >>> execute(["a", "This is a comment about a"], manipulate_encodings=False)
     >>> bd._clear_bugs()
-    >>> bug = bd.bug_from_shortname("a")
+    >>> bug = cmdutil.bug_from_shortname(bd, "a")
     >>> bug.load_comments(load_full=False)
     >>> comment = bug.comment_root[0]
     >>> print comment.body
     This is a comment about a
     <BLANKLINE>
-    >>> comment.From == bd.user_id
+    >>> comment.author == bd.user_id
     True
     >>> comment.time <= int(time.time())
     True
@@ -47,19 +47,20 @@ def execute(args, test=False):
 
     >>> if 'EDITOR' in os.environ:
     ...     del os.environ["EDITOR"]
-    >>> execute(["b"], test=True)
+    >>> execute(["b"], manipulate_encodings=False)
     Traceback (most recent call last):
     UserError: No comment supplied, and EDITOR not specified.
 
     >>> os.environ["EDITOR"] = "echo 'I like cheese' > "
-    >>> execute(["b"], test=True)
+    >>> execute(["b"], manipulate_encodings=False)
     >>> bd._clear_bugs()
-    >>> bug = bd.bug_from_shortname("b")
+    >>> bug = cmdutil.bug_from_shortname(bd, "b")
     >>> bug.load_comments(load_full=False)
     >>> comment = bug.comment_root[0]
     >>> print comment.body
     I like cheese
     <BLANKLINE>
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -68,10 +69,10 @@ def execute(args, test=False):
         raise cmdutil.UsageError("Please specify a bug or comment id.")
     if len(args) > 2:
         raise cmdutil.UsageError("Too many arguments.")
-    
+
     shortname = args[0]
     if shortname.count(':') > 1:
-        raise cmdutil.UserError("Invalid id '%s'." % shortname)        
+        raise cmdutil.UserError("Invalid id '%s'." % shortname)
     elif shortname.count(':') == 1:
         # Split shortname generated by Comment.comment_shortnames()
         bugname = shortname.split(':')[0]
@@ -79,17 +80,17 @@ def execute(args, test=False):
     else:
         bugname = shortname
         is_reply = False
-    
+
     bd = bugdir.BugDir(from_disk=True,
-                       manipulate_encodings=not test)
-    bug = bd.bug_from_shortname(bugname)
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, bugname)
     bug.load_comments(load_full=False)
     if is_reply:
         parent = bug.comment_root.comment_from_shortname(shortname,
                                                          bug_shortname=bugname)
     else:
         parent = bug.comment_root
-    
+
     if len(args) == 1: # try to launch an editor for comment-body entry
         try:
             if parent == bug.comment_root:
@@ -103,7 +104,6 @@ def execute(args, test=False):
             raise cmdutil.UserError, "No comment supplied, and EDITOR not specified."
         if body is None:
             raise cmdutil.UserError("No comment entered.")
-        body = body.decode('utf-8')
     elif args[1] == '-': # read body from stdin
         binary = not (options.content_type == None
                       or options.content_type.startswith("text/"))
@@ -117,11 +117,11 @@ def execute(args, test=False):
         body = args[1]
         if not body.endswith('\n'):
             body+='\n'
-    
+
     if options.XML == False:
         new = parent.new_reply(body=body)
         if options.author != None:
-            new.From = options.author
+            new.author = options.author
         if options.alt_id != None:
             new.alt_id = options.alt_id
         if options.content_type != None:
index 4f3bdbda07dd3c9939a1154817e05d69b4f5687a..dc70e7ea2ae6a7c488d17a504caf565b5473b4f7 100644 (file)
@@ -14,7 +14,7 @@
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 """Commit the currently pending changes to the repository"""
-from libbe import cmdutil, bugdir, editor, rcs
+from libbe import cmdutil, bugdir, editor, vcs
 import sys
 __desc__ = __doc__
 
@@ -22,13 +22,14 @@ def execute(args, manipulate_encodings=True):
     """
     >>> import os, time
     >>> from libbe import bug
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
     >>> full_path = "testfile"
     >>> test_contents = "A test file"
-    >>> bd.rcs.set_file_contents(full_path, test_contents)
+    >>> bd.vcs.set_file_contents(full_path, test_contents)
     >>> execute(["Added %s." % (full_path)], manipulate_encodings=False) # doctest: +ELLIPSIS
     Committed ...
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -48,11 +49,11 @@ def execute(args, manipulate_encodings=True):
     elif options.body == "EDITOR":
         body = editor.editor_string("Please enter your commit message above")
     else:
-        body = bd.rcs.get_file_contents(options.body, allow_no_rcs=True)
+        body = bd.vcs.get_file_contents(options.body, allow_no_vcs=True)
     try:
-        revision = bd.rcs.commit(summary, body=body,
+        revision = bd.vcs.commit(summary, body=body,
                                  allow_empty=options.allow_empty)
-    except rcs.EmptyCommit, e:
+    except vcs.EmptyCommit, e:
         print e
         return 1
     else:
index 4a23b0f8a2c9ee94896d6b3490903c863a07aa71..f72b8ba4094837b5d55794f1b5490b6e23c94ad7 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 """Add/remove bug dependencies"""
-from libbe import cmdutil, bugdir
+from libbe import cmdutil, bugdir, tree
 import os, copy
 __desc__ = __doc__
 
-def execute(args, test=False):
+BLOCKS_TAG="BLOCKS:"
+BLOCKED_BY_TAG="BLOCKED-BY:"
+
+class BrokenLink (Exception):
+    def __init__(self, blocked_bug, blocking_bug, blocks=True):
+        if blocks == True:
+            msg = "Missing link: %s blocks %s" \
+                % (blocking_bug.uuid, blocked_bug.uuid)
+        else:
+            msg = "Missing link: %s blocked by %s" \
+                % (blocked_bug.uuid, blocking_bug.uuid)
+        Exception.__init__(self, msg)
+        self.blocked_bug = blocked_bug
+        self.blocking_bug = blocking_bug
+
+
+def execute(args, manipulate_encodings=True):
     """
     >>> from libbe import utility
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> bd.save()
     >>> os.chdir(bd.root)
-    >>> execute(["a", "b"], test=True)
-    Blocks on a:
+    >>> execute(["a", "b"], manipulate_encodings=False)
+    a blocked by:
     b
-    >>> execute(["a"], test=True)
-    Blocks on a:
+    >>> execute(["a"], manipulate_encodings=False)
+    a blocked by:
     b
-    >>> execute(["--show-status", "a"], test=True) # doctest: +NORMALIZE_WHITESPACE
-    Blocks on a:
+    >>> execute(["--show-status", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    a blocked by:
+    b closed
+    >>> execute(["b", "a"], manipulate_encodings=False)
+    b blocked by:
+    a
+    b blocks:
+    a
+    >>> execute(["--show-status", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    a blocked by:
+    b closed
+    a blocks:
     b closed
-    >>> execute(["-r", "a", "b"], test=True)
+    >>> execute(["-r", "b", "a"], manipulate_encodings=False)
+    b blocks:
+    a
+    >>> execute(["-r", "a", "b"], manipulate_encodings=False)
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -41,45 +71,83 @@ def execute(args, test=False):
                              bugid_args={0: lambda bug : bug.active==True,
                                          1: lambda bug : bug.active==True})
 
-    if len(args) < 1:
+    if options.repair == True:
+        if len(args) > 0:
+            raise cmdutil.UsageError("No arguments with --repair calls.")
+    elif len(args) < 1:
         raise cmdutil.UsageError("Please a bug id.")
-    if len(args) > 2:
+    elif len(args) > 2:
         help()
         raise cmdutil.UsageError("Too many arguments.")
-    
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
-    bugA = bd.bug_from_shortname(args[0])
+    elif len(args) == 2 and options.tree_depth != None:
+        raise cmdutil.UsageError("Only one bug id used in tree mode.")
+        
+
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if options.repair == True:
+        good,fixed,broken = check_dependencies(bd, repair_broken_links=True)
+        assert len(broken) == 0, broken
+        if len(fixed) > 0:
+            print "Fixed the following links:"
+            print "\n".join(["%s |-- %s" % (blockee.uuid, blocker.uuid)
+                             for blockee,blocker in fixed])
+        return 0
+
+    bugA = cmdutil.bug_from_shortname(bd, args[0])
+
+    if options.tree_depth != None:
+        dtree = DependencyTree(bd, bugA, options.tree_depth)
+        if len(dtree.blocked_by_tree()) > 0:
+            print "%s blocked by:" % bugA.uuid
+            for depth,node in dtree.blocked_by_tree().thread():
+                if depth == 0: continue
+                print "%s%s" % (" "*(depth), node.bug.string(shortlist=True))
+        if len(dtree.blocks_tree()) > 0:
+            print "%s blocks:" % bugA.uuid
+            for depth,node in dtree.blocks_tree().thread():
+                if depth == 0: continue
+                print "%s%s" % (" "*(depth), node.bug.string(shortlist=True))
+        return 0
+
     if len(args) == 2:
-        bugB = bd.bug_from_shortname(args[1])
-        estrs = bugA.extra_strings
-        depend_string = "BLOCKED-BY:%s" % bugB.uuid
+        bugB = cmdutil.bug_from_shortname(bd, args[1])
         if options.remove == True:
-            estrs.remove(depend_string)
+            remove_block(bugA, bugB)
         else: # add the dependency
-            estrs.append(depend_string)
-        bugA.extra_strings = estrs # reassign to notice change
-
-    depends = []
-    for estr in bugA.extra_strings:
-        if estr.startswith("BLOCKED-BY:"):
-            uuid = estr[11:]
-            if options.show_status == True:
-                blocker = bd.bug_from_uuid(uuid)
-                block_string = "%s\t%s" % (uuid, blocker.status)
-            else:
-                block_string = uuid
-            depends.append(block_string)
-    if len(depends) > 0:
-        print "Blocks on %s:" % bugA.uuid
-        print '\n'.join(depends)
+            add_block(bugA, bugB)
+
+    blocked_by = get_blocked_by(bd, bugA)
+    if len(blocked_by) > 0:
+        print "%s blocked by:" % bugA.uuid
+        if options.show_status == True:
+            print '\n'.join(["%s\t%s" % (bug.uuid, bug.status)
+                             for bug in blocked_by])
+        else:
+            print '\n'.join([bug.uuid for bug in blocked_by])
+    blocks = get_blocks(bd, bugA)
+    if len(blocks) > 0:
+        print "%s blocks:" % bugA.uuid
+        if options.show_status == True:
+            print '\n'.join(["%s\t%s" % (bug.uuid, bug.status)
+                             for bug in blocks])
+        else:
+            print '\n'.join([bug.uuid for bug in blocks])
 
 def get_parser():
-    parser = cmdutil.CmdOptionParser("be depend BUG-ID [BUG-ID]")
-    parser.add_option("-r", "--remove", action="store_true", dest="remove",
+    parser = cmdutil.CmdOptionParser("be depend BUG-ID [BUG-ID]\nor:    be depend --repair")
+    parser.add_option("-r", "--remove", action="store_true",
+                      dest="remove", default=False,
                       help="Remove dependency (instead of adding it)")
     parser.add_option("-s", "--show-status", action="store_true",
-                      dest="show_status",
+                      dest="show_status", default=False,
                       help="Show status of blocking bugs")
+    parser.add_option("-t", "--tree-depth", metavar="DEPTH", default=None,
+                      type="int", dest="tree_depth",
+                      help="Print dependency tree rooted at BUG-ID with DEPTH levels of both blockers and blockees.  Set DEPTH <= 0 to disable the depth limit.")
+    parser.add_option("--repair", action="store_true",
+                      dest="repair", default=False,
+                      help="Check for and repair one-way links")
     return parser
 
 longhelp="""
@@ -88,7 +156,184 @@ If bug B is not specified, just print a list of bugs blocking (A).
 
 To search for bugs blocked by a particular bug, try
   $ be list --extra-strings BLOCKED-BY:<your-bug-uuid>
+
+In repair mode, add the missing direction to any one-way links.
+
+The "|--" symbol in the repair-mode output is inspired by the
+"negative feedback" arrow common in biochemistry.  See, for example
+  http://www.nature.com/nature/journal/v456/n7223/images/nature07513-f5.0.jpg
 """
 
 def help():
     return get_parser().help_str() + longhelp
+
+# internal helper functions
+
+def _generate_blocks_string(blocked_bug):
+    return "%s%s" % (BLOCKS_TAG, blocked_bug.uuid)
+
+def _generate_blocked_by_string(blocking_bug):
+    return "%s%s" % (BLOCKED_BY_TAG, blocking_bug.uuid)
+
+def _parse_blocks_string(string):
+    assert string.startswith(BLOCKS_TAG)
+    return string[len(BLOCKS_TAG):]
+
+def _parse_blocked_by_string(string):
+    assert string.startswith(BLOCKED_BY_TAG)
+    return string[len(BLOCKED_BY_TAG):]
+
+def _add_remove_extra_string(bug, string, add):
+    estrs = bug.extra_strings
+    if add == True:
+        estrs.append(string)
+    else: # remove the string
+        estrs.remove(string)
+    bug.extra_strings = estrs # reassign to notice change
+
+def _get_blocks(bug):
+    uuids = []
+    for line in bug.extra_strings:
+        if line.startswith(BLOCKS_TAG):
+            uuids.append(_parse_blocks_string(line))
+    return uuids
+
+def _get_blocked_by(bug):
+    uuids = []
+    for line in bug.extra_strings:
+        if line.startswith(BLOCKED_BY_TAG):
+            uuids.append(_parse_blocked_by_string(line))
+    return uuids
+
+def _repair_one_way_link(blocked_bug, blocking_bug, blocks=None):
+    if blocks == True: # add blocks link
+        blocks_string = _generate_blocks_string(blocked_bug)
+        _add_remove_extra_string(blocking_bug, blocks_string, add=True)
+    else: # add blocked by link
+        blocked_by_string = _generate_blocked_by_string(blocking_bug)
+        _add_remove_extra_string(blocked_bug, blocked_by_string, add=True)
+
+# functions exposed to other modules
+
+def add_block(blocked_bug, blocking_bug):
+    blocked_by_string = _generate_blocked_by_string(blocking_bug)
+    _add_remove_extra_string(blocked_bug, blocked_by_string, add=True)
+    blocks_string = _generate_blocks_string(blocked_bug)
+    _add_remove_extra_string(blocking_bug, blocks_string, add=True)
+
+def remove_block(blocked_bug, blocking_bug):
+    blocked_by_string = _generate_blocked_by_string(blocking_bug)
+    _add_remove_extra_string(blocked_bug, blocked_by_string, add=False)
+    blocks_string = _generate_blocks_string(blocked_bug)
+    _add_remove_extra_string(blocking_bug, blocks_string, add=False)
+
+def get_blocks(bugdir, bug):
+    """
+    Return a list of bugs that the given bug blocks.
+    """
+    blocks = []
+    for uuid in _get_blocks(bug):
+        blocks.append(bugdir.bug_from_uuid(uuid))
+    return blocks
+
+def get_blocked_by(bugdir, bug):
+    """
+    Return a list of bugs blocking the given bug blocks.
+    """
+    blocked_by = []
+    for uuid in _get_blocked_by(bug):
+        blocked_by.append(bugdir.bug_from_uuid(uuid))
+    return blocked_by
+
+def check_dependencies(bugdir, repair_broken_links=False):
+    """
+    Check that links are bi-directional for all bugs in bugdir.
+
+    >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+    >>> a = bd.bug_from_uuid("a")
+    >>> b = bd.bug_from_uuid("b")
+    >>> blocked_by_string = _generate_blocked_by_string(b)
+    >>> _add_remove_extra_string(a, blocked_by_string, add=True)
+    >>> good,repaired,broken = check_dependencies(bd, repair_broken_links=False)
+    >>> good
+    []
+    >>> repaired
+    []
+    >>> broken
+    [(Bug(uuid='a'), Bug(uuid='b'))]
+    >>> _get_blocks(b)
+    []
+    >>> good,repaired,broken = check_dependencies(bd, repair_broken_links=True)
+    >>> _get_blocks(b)
+    ['a']
+    >>> good
+    []
+    >>> repaired
+    [(Bug(uuid='a'), Bug(uuid='b'))]
+    >>> broken
+    []
+    """
+    if bugdir.sync_with_disk == True:
+        bugdir.load_all_bugs()
+    good_links = []
+    fixed_links = []
+    broken_links = []
+    for bug in bugdir:
+        for blocker in get_blocked_by(bugdir, bug):
+            blocks = get_blocks(bugdir, blocker)
+            if (bug, blocks) in good_links+fixed_links+broken_links:
+                continue # already checked that link
+            if bug not in blocks:
+                if repair_broken_links == True:
+                    _repair_one_way_link(bug, blocker, blocks=True)
+                    fixed_links.append((bug, blocker))
+                else:
+                    broken_links.append((bug, blocker))
+            else:
+                good_links.append((bug, blocker))
+        for blockee in get_blocks(bugdir, bug):
+            blocked_by = get_blocked_by(bugdir, blockee)
+            if (blockee, bug) in good_links+fixed_links+broken_links:
+                continue # already checked that link
+            if bug not in blocked_by:
+                if repair_broken_links == True:
+                    _repair_one_way_link(blockee, bug, blocks=False)
+                    fixed_links.append((blockee, bug))
+                else:
+                    broken_links.append((blockee, bug))
+            else:
+                good_links.append((blockee, bug))
+    return (good_links, fixed_links, broken_links)
+
+class DependencyTree (object):
+    """
+    Note: should probably be DependencyDiGraph.
+    """
+    def __init__(self, bugdir, root_bug, depth_limit=0):
+        self.bugdir = bugdir
+        self.root_bug = root_bug
+        self.depth_limit = depth_limit
+    def _build_tree(self, child_fn):
+        root = tree.Tree()
+        root.bug = self.root_bug
+        root.depth = 0
+        stack = [root]
+        while len(stack) > 0:
+            node = stack.pop()
+            if self.depth_limit > 0 and node.depth == self.depth_limit:
+                continue
+            for bug in child_fn(self.bugdir, node.bug):
+                child = tree.Tree()
+                child.bug = bug
+                child.depth = node.depth+1
+                node.append(child)
+                stack.append(child)
+        return root
+    def blocks_tree(self):
+        if not hasattr(self, "_blocks_tree"):
+            self._blocks_tree = self._build_tree(get_blocks)
+        return self._blocks_tree
+    def blocked_by_tree(self):
+        if not hasattr(self, "_blocked_by_tree"):
+            self._blocked_by_tree = self._build_tree(get_blocked_by)
+        return self._blocked_by_tree
index 13402c0768c3657485398f248ad2a4288000ac3e..b6ac5b07c4f97c434d2635c7c23c8665ff0d07a0 100644 (file)
@@ -20,23 +20,35 @@ from libbe import cmdutil, bugdir, diff
 import os
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> bd.set_sync_with_disk(True)
-    >>> original = bd.rcs.commit("Original status")
+    >>> original = bd.vcs.commit("Original status")
     >>> bug = bd.bug_from_uuid("a")
     >>> bug.status = "closed"
-    >>> changed = bd.rcs.commit("Closed bug a")
+    >>> changed = bd.vcs.commit("Closed bug a")
     >>> os.chdir(bd.root)
-    >>> if bd.rcs.versioned == True:
-    ...     execute([original], test=True)
+    >>> if bd.vcs.versioned == True:
+    ...     execute([original], manipulate_encodings=False)
     ... else:
-    ...     print "a:cm: Bug A\\nstatus: open -> closed\\n"
-    Modified bug reports:
-    a:cm: Bug A
-      status: open -> closed
+    ...     print "Modified bugs:\\n  a:cm: Bug A\\n    Changed bug settings:\\n      status: open -> closed"
+    Modified bugs:
+      a:cm: Bug A
+        Changed bug settings:
+          status: open -> closed
+    >>> if bd.vcs.versioned == True:
+    ...     execute(["--modified", original], manipulate_encodings=False)
+    ... else:
+    ...     print "a"
+    a
+    >>> if bd.vcs.versioned == False:
+    ...     execute([original], manipulate_encodings=False)
+    ... else:
+    ...     print "This directory is not revision-controlled."
+    This directory is not revision-controlled.
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -47,28 +59,31 @@ def execute(args, test=False):
         revision = args[0]
     if len(args) > 1:
         raise cmdutil.UsageError("Too many arguments.")
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
-    if bd.rcs.versioned == False:
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if bd.vcs.versioned == False:
         print "This directory is not revision-controlled."
     else:
+        if revision == None: # get the most recent revision
+            revision = bd.vcs.revision_id(-1)
         old_bd = bd.duplicate_bugdir(revision)
-        r,m,a = diff.bug_diffs(old_bd, bd)
-        
-        optbugs = []
+        d = diff.Diff(old_bd, bd)
+        tree = d.report_tree()
+
+        uuids = []
         if options.all == True:
             options.new = options.modified = options.removed = True
         if options.new == True:
-            optbugs.extend(a)
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/new")])
         if options.modified == True:
-            optbugs.extend([new for old,new in m])
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/mod")])
         if options.removed == True:
-            optbugs.extend(r)
-        if len(optbugs) > 0:
-            for bug in optbugs:
-                print bug.uuid
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/rem")])
+        if (options.new or options.modified or options.removed) == True:
+            print "\n".join(uuids)
         else :
-            rep = diff.diff_report((r,m,a), old_bd, bd).encode(bd.encoding)
-            if len(rep) > 0:
+            rep = tree.report_string()
+            if rep != None:
                 print rep
         bd.remove_duplicate_bugdir()
 
@@ -85,14 +100,14 @@ def get_parser():
         long = "--%s" % s[1]
         help = s[2]
         parser.add_option(short, long, action="store_true",
-                          dest=attr, help=help)
+                          default=False, dest=attr, help=help)
     return parser
 
 longhelp="""
-Uses the RCS to compare the current tree with a previous tree, and
+Uses the VCS to compare the current tree with a previous tree, and
 prints a pretty report.  If REVISION is given, it is a specifier for
 the particular previous tree to use.  Specifiers are specific to their
-RCS.
+VCS.
 
 For Arch your specifier must be a fully-qualified revision name.
 
index a8ae3383b8e46a30c5dbbd74b577bc4e379e0aa9..a8f346acf6d5481e4c0bb9194e327451d3b41b65 100644 (file)
 from libbe import cmdutil, utility
 __desc__ = __doc__
 
-def execute(args):
+def execute(args, manipulate_encodings=False):
     """
-    Print help of specified command.
+    Print help of specified command (the manipulate_encodings argument
+    is ignored).
+
     >>> execute(["help"])
     Usage: be help [COMMAND]
     <BLANKLINE>
index 483522745f5e4f23e6d7bd108f93fe2b877eb30b..908c714d0f4f6ba9a86cfc6ba3bc86ec75bd26d6 100644 (file)
@@ -1,40 +1,47 @@
-# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
-#                         Marien Zwart <marienz@gentoo.org>
-#                         Thomas Gerigk <tgerigk@gmx.de>
-#                         W. Trevor King <wking@drexel.edu>
-# <abentley@panoramicfeedback.com>
+# Copyright (C) 2009 Gianluca Montecchi <gian@grys.it>
+#                    W. Trevor King <wking@drexel.edu>
 #
-#    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 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.
+# 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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
-"""Re-open a bug"""
+# 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.
+"""Generate a static HTML dump of the current repository status"""
 from libbe import cmdutil, bugdir, bug
 #from html_data import *
-import os,  re,  time, string
+import codecs, os, re, string, time
+import xml.sax.saxutils, htmlentitydefs
 
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
-    >>> print bd.bug_from_shortname("b").status
-    closed
-    >>> execute(["b"], test=True)
-    >>> bd._clear_bugs()
-    >>> print bd.bug_from_shortname("b").status
-    open
+    >>> execute([], manipulate_encodings=False)
+    Creating the html output in html_export
+    >>> os.path.exists("./html_export")
+    True
+    >>> os.path.exists("./html_export/index.html")
+    True
+    >>> os.path.exists("./html_export/index_inactive.html")
+    True
+    >>> os.path.exists("./html_export/bugs")
+    True
+    >>> os.path.exists("./html_export/bugs/a.html")
+    True
+    >>> os.path.exists("./html_export/bugs/b.html")
+    True
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -50,7 +57,8 @@ def execute(args, test=False):
     if len(args) > 0:
         raise cmdutil.UsageError, "Too many arguments."
     
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
     bd.load_all_bugs()
     status_list = bug.status_values
     severity_list = bug.severity_values
@@ -61,9 +69,9 @@ def execute(args, test=False):
     bugs_inactive = []
     for s in status_list:
         st[s] = 0
-    for b in bd:
+    for b in sorted(bd, reverse=True):
         stime[b.uuid]  = b.time
-        if b.status in ["open", "test", "unconfirmed", "assigned"]:
+        if b.active == True:
             bugs_active.append(b)
         else:
             bugs_inactive.append(b)
@@ -73,8 +81,8 @@ def execute(args, test=False):
     #open_bug_list = sorted([(value,key) for (key,value) in bugs.items()])
     
     html_gen = BEHTMLGen(bd)
-    html_gen.create_index_file(out_dir,  st, bugs_active, ordered_bug_list, "active")
-    html_gen.create_index_file(out_dir,  st, bugs_inactive, ordered_bug_list, "inactive")
+    html_gen.create_index_file(out_dir,  st, bugs_active, ordered_bug_list, "active", bd.encoding)
+    html_gen.create_index_file(out_dir,  st, bugs_inactive, ordered_bug_list, "inactive", bd.encoding)
     
 def get_parser():
     parser = cmdutil.CmdOptionParser("be open OUTPUT_DIR")
@@ -83,7 +91,8 @@ def get_parser():
     return parser
 
 longhelp="""
-Generate a set of html pages.
+Generate a set of html pages representing the current state of the bug
+directory.
 """
 
 def help():
@@ -94,7 +103,18 @@ def complete(options, args, parser):
         if "--complete" in args:
             raise cmdutil.GetCompletions() # no positional arguments for list
         
-    
+
+def escape(string):
+    if string == None:
+        return ""
+    chars = []
+    for char in xml.sax.saxutils.escape(string):
+        codepoint = ord(char)
+        if codepoint in htmlentitydefs.codepoint2name:
+            char = "&%s;" % htmlentitydefs.codepoint2name[codepoint]
+        chars.append(char)
+    return "".join(chars)
+
 class BEHTMLGen():
     def __init__(self, bd):
         self.index_value = ""    
@@ -353,10 +373,12 @@ class BEHTMLGen():
         """
         
         self.index_first = """
+        <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
         <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
         <head>
         <title>BugsEverywhere Issue Tracker</title>
-        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+        <meta http-equiv="Content-Type" content="text/html; charset=%s" />
         <link rel="stylesheet" href="style.css" type="text/css" />
         </head>
         <body>
@@ -368,17 +390,17 @@ class BEHTMLGen():
         <table>
         
         <tr>
-        <td class=%s><a href="index.html">Active Bugs</a></td>
-        <td class=%s><a href="index_inactive.html">Inactive Bugs</a></td>
+        <td class="%%s"><a href="index.html">Active Bugs</a></td>
+        <td class="%%s"><a href="index_inactive.html">Inactive Bugs</a></td>
         </tr>
         
         </table>
-        <table class=table_bug>
+        <table class="table_bug">
         <tbody>
-        """    
+        """ % self.bd.encoding
         
         self.bug_line ="""
-        <tr class=%s-row cellspacing=1>
+        <tr class="%s-row">
         <td ><a href="bugs/%s.html">%s</a></td>
         <td ><a href="bugs/%s.html">%s</a></td>
         <td><a href="bugs/%s.html">%s</a></td>
@@ -388,10 +410,12 @@ class BEHTMLGen():
         """
         
         self.detail_first = """
+        <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
         <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
         <head>
         <title>BugsEverywhere Issue Tracker</title>
-        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+        <meta http-equiv="Content-Type" content="text/html; charset=%s" />
         <link rel="stylesheet" href="../style.css" type="text/css" />
         </head>
         <body>
@@ -399,11 +423,11 @@ class BEHTMLGen():
         
         <div class="main">
         <h1>BugsEverywhere Bug List</h1>
-        <h5><a href="%s">Back to Index</a></h5>
+        <h5><a href="%%s">Back to Index</a></h5>
         <h2>Bug: _bug_id_</h2>
         <table >
         <tbody>
-        """   
+        """ % self.bd.encoding
         
         
         
@@ -430,7 +454,7 @@ class BEHTMLGen():
         
         self.begin_comment_section ="""
         <tr>
-        <td align=right>Comments:
+        <td align="right">Comments:
         </td>
         <td>
         """
@@ -452,7 +476,7 @@ class BEHTMLGen():
         """   
         
         
-    def create_index_file(self, out_dir_path,  summary,  bugs, ordered_bug, fileid):
+    def create_index_file(self, out_dir_path,  summary,  bugs, ordered_bug, fileid, encoding):
         try:
             os.stat(out_dir_path)
         except:
@@ -461,7 +485,7 @@ class BEHTMLGen():
             except:
                 raise  cmdutil.UsageError, "Cannot create output directory."
         try:
-            FO = open(out_dir_path+"/style.css", "w")
+            FO = codecs.open(out_dir_path+"/style.css", "w", encoding)
             FO.write(self.css_file)
             FO.close()
         except:
@@ -474,10 +498,10 @@ class BEHTMLGen():
         
         try:
             if fileid == "active":
-                FO = open(out_dir_path+"/index.html", "w")
+                FO = codecs.open(out_dir_path+"/index.html", "w", encoding)
                 FO.write(self.index_first%('td_sel','td_nsel'))
             if fileid == "inactive":
-                FO = open(out_dir_path+"/index_inactive.html", "w")
+                FO = codecs.open(out_dir_path+"/index_inactive.html", "w", encoding)
                 FO.write(self.index_first%('td_nsel','td_sel'))
         except:
             raise  cmdutil.UsageError, "Cannot create the index.html file."
@@ -485,25 +509,25 @@ class BEHTMLGen():
         c = 0
         t = len(bugs) - 1
         for l in range(t,  -1,  -1):
-            line = self.bug_line%(bugs[l].severity,
-            bugs[l].uuid, bugs[l].uuid[0:3],
-            bugs[l].uuid,  bugs[l].status,
-            bugs[l].uuid,  bugs[l].severity,
-            bugs[l].uuid,  bugs[l].summary,
-            bugs[l].uuid,  bugs[l].time_string
-            )
+            line = self.bug_line%(escape(bugs[l].severity),
+                                  escape(bugs[l].uuid), escape(bugs[l].uuid[0:3]),
+                                  escape(bugs[l].uuid), escape(bugs[l].status),
+                                  escape(bugs[l].uuid), escape(bugs[l].severity),
+                                  escape(bugs[l].uuid), escape(bugs[l].summary),
+                                  escape(bugs[l].uuid), escape(bugs[l].time_string)
+                                  )
             FO.write(line)
             c += 1
-            self.create_detail_file(bugs[l], out_dir_path, fileid)
+            self.create_detail_file(bugs[l], out_dir_path, fileid, encoding)
         when = time.ctime()
         FO.write(self.index_last%when)
 
 
-    def create_detail_file(self, bug, out_dir_path, fileid):
+    def create_detail_file(self, bug, out_dir_path, fileid, encoding):
         f = "%s.html"%bug.uuid
         p = out_dir_path+"/bugs/"+f
         try:
-            FD = open(p, "w")
+            FD = codecs.open(p, "w", encoding)
         except:
             raise  cmdutil.UsageError, "Cannot create the detail html file."
 
@@ -519,53 +543,41 @@ class BEHTMLGen():
         bug_.load_comments(load_full=True)
         
         FD.write(self.detail_line%("ID : ", bug.uuid))
-        FD.write(self.detail_line%("Short name : ", bug.uuid[0:3]))
-        FD.write(self.detail_line%("Severity : ", bug.severity))
-        FD.write(self.detail_line%("Status : ", bug.status))
-        FD.write(self.detail_line%("Assigned : ", bug.assigned))
-        FD.write(self.detail_line%("Target : ", bug.target))
-        FD.write(self.detail_line%("Reporter : ", bug.reporter))
-        FD.write(self.detail_line%("Creator : ", bug.creator))
-        FD.write(self.detail_line%("Created : ", bug.time_string))
-        FD.write(self.detail_line%("Summary : ", bug.summary))
-        FD.write("<tr><td colspan=2><hr></td></tr>")
+        FD.write(self.detail_line%("Short name : ", escape(bug.uuid[0:3])))
+        FD.write(self.detail_line%("Severity : ", escape(bug.severity)))
+        FD.write(self.detail_line%("Status : ", escape(bug.status)))
+        FD.write(self.detail_line%("Assigned : ", escape(bug.assigned)))
+        FD.write(self.detail_line%("Target : ", escape(bug.target)))
+        FD.write(self.detail_line%("Reporter : ", escape(bug.reporter)))
+        FD.write(self.detail_line%("Creator : ", escape(bug.creator)))
+        FD.write(self.detail_line%("Created : ", escape(bug.time_string)))
+        FD.write(self.detail_line%("Summary : ", escape(bug.summary)))
+        FD.write("<tr><td colspan=\"2\"><hr /></td></tr>")
         FD.write(self.begin_comment_section)
         tr = []
         b = ''
         level = 0
-        for i in bug_.comments():
-            if not isinstance(i.in_reply_to,str):
-                first = True
-                a = i.string_thread(flatten=False)
-                d = re.split('\n',a)
-                for x in range(0,len(d)):
-                    hr = ""
-                    if re.match(" *--------- Comment ---------",d[x]):
-                        com = """
-                        %s<br>
-                        %s<br>
-                        %s<br>
-                        %s<br>
-                        %s<br>
-                        """%(d[x+1],d[x+2],d[x+3],d[x+4],d[x+5])
-                        l = re.sub("--------- Comment ---------", "", d[x])
-                        ll = l.split("  ")
-                        la = l
-                        ba = ""
-                        if len(la) > level:
-                            FD.write("<div class='comment'>")
-                        if len(la) < level:
-                            FD.write("</div>")
-                        if len(la) == 0:
-                            if not first :
-                                FD.write("</div>")
-                                first = False
-                            FD.write("<div class='commentF'>")
-                        level = len(la)
-                        x += 5
-                        FD.write("--------- Comment ---------<p />")
-                        FD.write(com)
-                FD.write("</div>")
+        stack = []
+        for depth,comment in bug_.comment_root.thread(flatten=False):
+            while len(stack) > depth:
+                stack.pop(-1)      # pop non-parents off the stack
+                FD.write("</div>\n") # close non-parent <div class="comment...
+            assert len(stack) == depth
+            stack.append(comment)
+            lines = ["--------- Comment ---------",
+                     "Name: %s" % comment.uuid,
+                     "From: %s" % escape(comment.author),
+                     "Date: %s" % escape(comment.date),
+                     ""]
+            lines.extend(escape(comment.body).splitlines())
+            if depth == 0:
+                FD.write('<div class="commentF">')
+            else:
+                FD.write('<div class="comment">')
+            FD.write("<br />\n".join(lines)+"<br />\n")
+        while len(stack) > 0:
+            stack.pop(-1)
+            FD.write("</div>\n") # close every remaining <div class="comment...
         FD.write(self.end_comment_section)
         if fileid == "active":
             FD.write(self.detail_last%"../index.html")
@@ -573,4 +585,4 @@ class BEHTMLGen():
             FD.write(self.detail_last%"../index_inactive.html")
         FD.close()
         
-   
\ No newline at end of file
+   
index 5b2a416f13d3d5a49d34fe5d73fa5e0f72b8ba80..1125d937e158940fb5eb41ddf8ea5f488fe7f859 100644 (file)
@@ -19,9 +19,9 @@ import os.path
 from libbe import cmdutil, bugdir
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
-    >>> from libbe import utility, rcs
+    >>> from libbe import utility, vcs
     >>> import os
     >>> dir = utility.Dir()
     >>> try:
@@ -29,28 +29,28 @@ def execute(args, test=False):
     ... except bugdir.NoBugDir, e:
     ...     True
     True
-    >>> execute(['--root', dir.path], test=True)
+    >>> execute(['--root', dir.path], manipulate_encodings=False)
     No revision control detected.
     Directory initialized.
     >>> del(dir)
 
     >>> dir = utility.Dir()
     >>> os.chdir(dir.path)
-    >>> rcs = rcs.installed_rcs()
-    >>> rcs.init('.')
-    >>> print rcs.name
+    >>> vcs = vcs.installed_vcs()
+    >>> vcs.init('.')
+    >>> print vcs.name
     Arch
-    >>> execute([], test=True)
+    >>> execute([], manipulate_encodings=False)
     Using Arch for revision control.
     Directory initialized.
-    >>> rcs.cleanup()
+    >>> vcs.cleanup()
 
     >>> try:
-    ...     execute(['--root', '.'], test=True)
+    ...     execute(['--root', '.'], manipulate_encodings=False)
     ... except cmdutil.UserError, e:
     ...     str(e).startswith("Directory already initialized: ")
     True
-    >>> execute(['--root', '/highly-unlikely-to-exist'], test=True)
+    >>> execute(['--root', '/highly-unlikely-to-exist'], manipulate_encodings=False)
     Traceback (most recent call last):
     UserError: No such directory: /highly-unlikely-to-exist
     >>> os.chdir('/')
@@ -64,14 +64,14 @@ def execute(args, test=False):
         bd = bugdir.BugDir(options.root_dir, from_disk=False,
                            sink_to_existing_root=False,
                            assert_new_BugDir=True,
-                           manipulate_encodings=not test)
+                           manipulate_encodings=manipulate_encodings)
     except bugdir.NoRootEntry:
         raise cmdutil.UserError("No such directory: %s" % options.root_dir)
     except bugdir.AlreadyInitialized:
         raise cmdutil.UserError("Directory already initialized: %s" % options.root_dir)
     bd.save()
-    if bd.rcs.name is not "None":
-        print "Using %s for revision control." % bd.rcs.name
+    if bd.vcs.name is not "None":
+        print "Using %s for revision control." % bd.vcs.name
     else:
         print "No revision control detected."
     print "Directory initialized."
@@ -86,7 +86,7 @@ def get_parser():
 longhelp="""
 This command initializes Bugs Everywhere support for the specified directory
 and all its subdirectories.  It will auto-detect any supported revision control
-system.  You can use "be set rcs_name" to change the rcs being used.
+system.  You can use "be set vcs_name" to change the vcs being used.
 
 The directory defaults to your current working directory.
 
index 5ba1821d3d6c09b8422c5bde74a195d643711041..12e1e29966199ba8372f39c0037cac8c23d956e7 100644 (file)
@@ -26,16 +26,17 @@ __desc__ = __doc__
 AVAILABLE_CMPS = [fn[4:] for fn in dir(bug) if fn[:4] == 'cmp_']
 AVAILABLE_CMPS.remove("attr") # a cmp_* template.
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
-    >>> execute([], test=True)
+    >>> execute([], manipulate_encodings=False)
     a:om: Bug A
-    >>> execute(["--status", "all"], test=True)
+    >>> execute(["--status", "all"], manipulate_encodings=False)
     a:om: Bug A
     b:cm: Bug B
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -46,11 +47,13 @@ def execute(args, test=False):
     if options.sort_by != None:
         for cmp in options.sort_by.split(','):
             if cmp not in AVAILABLE_CMPS:
-                raise cmdutil.UserError("Invalid sort on '%s'.\nValid sorts:\n  %s"
-                                        % (cmp, '\n  '.join(AVAILABLE_CMPS)))
+                raise cmdutil.UserError(
+                    "Invalid sort on '%s'.\nValid sorts:\n  %s"
+                    % (cmp, '\n  '.join(AVAILABLE_CMPS)))
             cmp_list.append(eval('bug.cmp_%s' % cmp))
     
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
     bd.load_all_bugs()
     # select status
     if options.status != None:
index 4aaefa85a7fd16ef83e47ec6a022eb2c45cc0252..f212b01fab9fdb7e67973de64efe8e42f584f18d 100644 (file)
@@ -18,10 +18,10 @@ from libbe import cmdutil, bugdir
 import os, copy
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> from libbe import utility
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> bd.set_sync_with_disk(True)
     >>> a = bd.bug_from_shortname("a")
     >>> a.comment_root.time = 0
@@ -37,7 +37,7 @@ def execute(args, test=False):
     >>> dummy = dummy.new_reply("1 2 3 4")
     >>> dummy.time = 2
     >>> os.chdir(bd.root)
-    >>> execute(["a", "b"], test=True)
+    >>> execute(["a", "b"], manipulate_encodings=False)
     Merging bugs a and b
     >>> bd._clear_bugs()
     >>> a = bd.bug_from_shortname("a")
@@ -120,6 +120,7 @@ def execute(args, test=False):
     Merged into bug a
     >>> print b.status
     closed
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -133,10 +134,11 @@ def execute(args, test=False):
         help()
         raise cmdutil.UsageError("Too many arguments.")
     
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
-    bugA = bd.bug_from_shortname(args[0])
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bugA = cmdutil.bug_from_shortname(bd, args[0])
     bugA.load_comments()
-    bugB = bd.bug_from_shortname(args[1])
+    bugB = cmdutil.bug_from_shortname(bd, args[1])
     bugB.load_comments()
     mergeA = bugA.new_comment("Merged from bug %s" % bugB.uuid)
     newCommTree = copy.deepcopy(bugB.comment_root)
index af599d797d09c81f4ccfaba5d67c059cf1c92128..a8ee2ec3ba36da7d7a5f72609f2a10000bd7ad9d 100644 (file)
@@ -19,16 +19,16 @@ from libbe import cmdutil, bugdir
 import sys
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os, time
     >>> from libbe import bug
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
     >>> bug.uuid_gen = lambda: "X"
-    >>> execute (["this is a test",], test=True)
+    >>> execute (["this is a test",], manipulate_encodings=False)
     Created bug with ID X
-    >>> bd.load()
+    >>> bd._clear_bugs()
     >>> bug = bd.bug_from_uuid("X")
     >>> print bug.summary
     this is a test
@@ -38,13 +38,15 @@ def execute(args, test=False):
     minor
     >>> bug.target == None
     True
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
     cmdutil.default_complete(options, args, parser)
     if len(args) != 1:
         raise cmdutil.UsageError("Please supply a summary message")
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
     if args[0] == '-': # read summary from stdin
         summary = sys.stdin.readline()
     else:
index 2ef5f4303aa628d58091bbc053b58e027194d6f8..0c6bf05b52a18674ce4e5ce127c2edb171abc416 100644 (file)
 from libbe import cmdutil, bugdir
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
     >>> print bd.bug_from_shortname("b").status
     closed
-    >>> execute(["b"], test=True)
+    >>> execute(["b"], manipulate_encodings=False)
     >>> bd._clear_bugs()
     >>> print bd.bug_from_shortname("b").status
     open
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -40,8 +41,9 @@ def execute(args, test=False):
         raise cmdutil.UsageError, "Please specify a bug id."
     if len(args) > 1:
         raise cmdutil.UsageError, "Too many arguments."
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
-    bug = bd.bug_from_shortname(args[0])
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
     bug.status = "open"
 
 def get_parser():
index d79a7bef125712497bf824370c65d85855ea88a9..8d850337a42323e5fe67941230565f3114c635f2 100644 (file)
 from libbe import cmdutil, bugdir
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> from libbe import mapfile
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
     >>> print bd.bug_from_shortname("b").status
     closed
-    >>> execute (["b"], test=True)
+    >>> execute (["b"], manipulate_encodings=False)
     Removed bug b
     >>> bd._clear_bugs()
     >>> try:
     ...     bd.bug_from_shortname("b")
-    ... except KeyError:
+    ... except bugdir.NoBugMatches:
     ...     print "Bug not found"
     Bug not found
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -40,8 +41,9 @@ def execute(args, test=False):
                              bugid_args={0: lambda bug : bug.active==True})
     if len(args) != 1:
         raise cmdutil.UsageError, "Please specify a bug id."
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
-    bug = bd.bug_from_shortname(args[0])
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
     bd.remove_bug(bug)
     print "Removed bug %s" % bug.uuid
 
index 0c0862f3966bc53c3545a66ddb1a43ced49f5b5d..f7e68d368bd303660786a2d59106daa6cf32b113 100644 (file)
@@ -19,7 +19,7 @@
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 """Change tree settings"""
 import textwrap
-from libbe import cmdutil, bugdir, rcs, settings_object
+from libbe import cmdutil, bugdir, vcs, settings_object
 __desc__ = __doc__
 
 def _value_string(bd, setting):
@@ -32,26 +32,28 @@ def _value_string(bd, setting):
             val = None
     return str(val)
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
-    >>> execute(["target"], test=True)
+    >>> execute(["target"], manipulate_encodings=False)
     None
-    >>> execute(["target", "tomorrow"], test=True)
-    >>> execute(["target"], test=True)
+    >>> execute(["target", "tomorrow"], manipulate_encodings=False)
+    >>> execute(["target"], manipulate_encodings=False)
     tomorrow
-    >>> execute(["target", "none"], test=True)
-    >>> execute(["target"], test=True)
+    >>> execute(["target", "none"], manipulate_encodings=False)
+    >>> execute(["target"], manipulate_encodings=False)
     None
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
     complete(options, args, parser)
     if len(args) > 2:
         raise cmdutil.UsageError, "Too many arguments"
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
     if len(args) == 0:
         keys = bd.settings_properties
         keys.sort()
@@ -85,12 +87,12 @@ def get_bugdir_settings():
         set = getattr(bugdir.BugDir, s)
         dstr = set.__doc__.strip()
         # per-setting comment adjustments
-        if s == "rcs_name":
+        if s == "vcs_name":
             lines = dstr.split('\n')
             while lines[0].startswith("This property defaults to") == False:
                 lines.pop(0)
             assert len(lines) != None, \
-                "Unexpected rcs_name docstring:\n  '%s'" % dstr
+                "Unexpected vcs_name docstring:\n  '%s'" % dstr
             lines.insert(
                 0, "The name of the revision control system to use.\n")
             dstr = '\n'.join(lines)
index 65467e3289bee5f0063df2744415043c797a14cd..660586e1e2092a9b89e28baf77213e0e216f76d4 100644 (file)
 from libbe import cmdutil, bugdir, bug
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
-    >>> execute(["a"], test=True)
+    >>> execute(["a"], manipulate_encodings=False)
     minor
-    >>> execute(["a", "wishlist"], test=True)
-    >>> execute(["a"], test=True)
+    >>> execute(["a", "wishlist"], manipulate_encodings=False)
+    >>> execute(["a"], manipulate_encodings=False)
     wishlist
-    >>> execute(["a", "none"], test=True)
+    >>> execute(["a", "none"], manipulate_encodings=False)
     Traceback (most recent call last):
     UserError: Invalid severity level: none
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
     complete(options, args, parser)
     if len(args) not in (1,2):
         raise cmdutil.UsageError
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
-    bug = bd.bug_from_shortname(args[0])
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
     if len(args) == 1:
         print bug.severity
     elif len(args) == 2:
index e43cfb9391303b0ae3256dfb1bb9bc525dbf108c..50bd6eb92cbaf0428066dcc66f7e98faf3fd7304 100644 (file)
@@ -22,12 +22,12 @@ import sys
 from libbe import cmdutil, bugdir
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
-    >>> execute (["a",], test=True) # doctest: +ELLIPSIS
+    >>> execute (["a",], manipulate_encodings=False) # doctest: +ELLIPSIS
               ID : a
       Short name : a
         Severity : minor
@@ -39,7 +39,7 @@ def execute(args, test=False):
          Created : ...
     Bug A
     <BLANKLINE>
-    >>> execute (["--xml", "a"], test=True) # doctest: +ELLIPSIS
+    >>> execute (["--xml", "a"], manipulate_encodings=False) # doctest: +ELLIPSIS
     <?xml version="1.0" encoding="..." ?>
     <bug>
       <uuid>a</uuid>
@@ -50,6 +50,7 @@ def execute(args, test=False):
       <created>...</created>
       <summary>Bug A</summary>
     </bug>
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -57,7 +58,8 @@ def execute(args, test=False):
                              bugid_args={-1: lambda bug : bug.active==True})
     if len(args) == 0:
         raise cmdutil.UsageError
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
     if options.XML:
         print '<?xml version="1.0" encoding="%s" ?>' % bd.encoding
     for shortname in args:
@@ -72,7 +74,7 @@ def execute(args, test=False):
             is_comment = False
         if is_comment == True and options.comments == False:
             continue
-        bug = bd.bug_from_shortname(bugname)
+        bug = cmdutil.bug_from_shortname(bd, bugname)
         if is_comment == False:
             if options.XML:
                 print bug.xml(show_comments=options.comments)
index edc948dc5820607710641cbbb6b2155195c36b2c..f3150031eed08e66c675e7545a565d8afa84d5b9 100644 (file)
 from libbe import cmdutil, bugdir, bug
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
-    >>> execute(["a"], test=True)
+    >>> execute(["a"], manipulate_encodings=False)
     open
-    >>> execute(["a", "closed"], test=True)
-    >>> execute(["a"], test=True)
+    >>> execute(["a", "closed"], manipulate_encodings=False)
+    >>> execute(["a"], manipulate_encodings=False)
     closed
-    >>> execute(["a", "none"], test=True)
+    >>> execute(["a", "none"], manipulate_encodings=False)
     Traceback (most recent call last):
     UserError: Invalid status: none
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
     complete(options, args, parser)
     if len(args) not in (1,2):
         raise cmdutil.UsageError
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
-    bug = bd.bug_from_shortname(args[0])
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
     if len(args) == 1:
         print bug.status
     else:
@@ -55,7 +57,8 @@ def get_parser():
 
 def help():
     try: # See if there are any per-tree status configurations
-        bd = bugdir.BugDir(from_disk=True, manipulate_encodings=False)
+        bd = bugdir.BugDir(from_disk=True,
+                           manipulate_encodings=False)
     except bugdir.NoBugDir, e:
         pass # No tree, just show the defaults
     longest_status_len = max([len(s) for s in bug.status_values])
diff --git a/becommands/subscribe.py b/becommands/subscribe.py
new file mode 100644 (file)
index 0000000..0a23057
--- /dev/null
@@ -0,0 +1,390 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""(Un)subscribe to change notification"""
+from libbe import cmdutil, bugdir, tree
+import os, copy
+__desc__ = __doc__
+
+TAG="SUBSCRIBE:"
+
+class SubscriptionType (tree.Tree):
+    """
+    Trees of subscription types to allow users to select exactly what
+    notifications they want to subscribe to.
+    """
+    def __init__(self, type_name, *args, **kwargs):
+        tree.Tree.__init__(self, *args, **kwargs)
+        self.type = type_name
+    def __str__(self):
+        return self.type
+    def __repr__(self):
+        return "<SubscriptionType: %s>" % str(self)
+    def string_tree(self, indent=0):
+        lines = []
+        for depth,node in self.thread():
+            lines.append("%s%s" % (" "*(indent+2*depth), node))
+        return "\n".join(lines)
+
+BUGDIR_TYPE_NEW = SubscriptionType("new")
+BUGDIR_TYPE_ALL = SubscriptionType("all", [BUGDIR_TYPE_NEW])
+
+# same name as BUGDIR_TYPE_ALL for consistency
+BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL))
+
+INVALID_TYPE = SubscriptionType("INVALID")
+
+class InvalidType (ValueError):
+    def __init__(self, type_name, type_root):
+        msg = "Invalid type %s for tree:\n%s" \
+            % (type_name, type_root.string_tree(4))
+        ValueError.__init__(self, msg)
+        self.type_name = type_name
+        self.type_root = type_root
+
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> bd = bugdir.SimpleBugDir()
+    >>> bd.set_sync_with_disk(True)
+    >>> os.chdir(bd.root)
+    >>> a = bd.bug_from_shortname("a")
+    >>> print a.extra_strings
+    []
+    >>> execute(["-s","John Doe <j@doe.com>", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    John Doe <j@doe.com>    all    *
+    >>> bd._clear_bugs() # resync our copy of bug
+    >>> a = bd.bug_from_shortname("a")
+    >>> print a.extra_strings
+    ['SUBSCRIBE:John Doe <j@doe.com>\\tall\\t*']
+    >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "a.com,b.net", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    Jane Doe <J@doe.com>    all    a.com,b.net
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "a.edu", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    Jane Doe <J@doe.com>    all    a.com,a.edu,b.net
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-u", "-s","Jane Doe <J@doe.com>", "-S", "a.com", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    Jane Doe <J@doe.com>    all    a.edu,b.net
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "*", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    Jane Doe <J@doe.com>    all    *
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-u", "-s","Jane Doe <J@doe.com>", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-u", "-s","John Doe <j@doe.com>", "a"], manipulate_encodings=False)
+    >>> execute(["-s","Jane Doe <J@doe.com>", "-t", "new", "DIR"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for bug directory:
+    Jane Doe <J@doe.com>    new    *
+    >>> execute(["-s","Jane Doe <J@doe.com>", "DIR"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for bug directory:
+    Jane Doe <J@doe.com>    all    *
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True})
+
+    if len(args) > 1:
+        help()
+        raise cmdutil.UsageError("Too many arguments.")
+
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+
+    subscriber = options.subscriber
+    if subscriber == None:
+        subscriber = bd.user_id
+    if options.unsubscribe == True:
+        if options.servers == None:
+            options.servers = "INVALID"
+        if options.types == None:
+            options.types = "INVALID"
+    else:
+        if options.servers == None:
+            options.servers = "*"
+        if options.types == None:
+            options.types = "all"
+    servers = options.servers.split(",")
+    types = options.types.split(",")
+
+    if len(args) == 0 or args[0] == "DIR": # directory-wide subscriptions
+        type_root = BUGDIR_TYPE_ALL
+        entity = bd
+        entity_name = "bug directory"
+    else: # bug-specific subscriptions
+        type_root = BUG_TYPE_ALL
+        bug = bd.bug_from_shortname(args[0])
+        entity = bug
+        entity_name = bug.uuid
+    if options.list_all == True:
+        entity_name = "anything in the bug directory"
+
+    types = [type_from_name(name, type_root, default=INVALID_TYPE,
+                            default_ok=options.unsubscribe)
+             for name in types]
+    estrs = entity.extra_strings
+    if options.list == True or options.list_all == True:
+        pass
+    else: # alter subscriptions
+        if options.unsubscribe == True:
+            estrs = unsubscribe(estrs, subscriber, types, servers, type_root)
+        else: # add the tag
+            estrs = subscribe(estrs, subscriber, types, servers, type_root)
+        entity.extra_strings = estrs # reassign to notice change
+
+    if options.list_all == True:
+        bd.load_all_bugs()
+        subscriptions = get_bugdir_subscribers(bd, servers[0])
+    else:
+        subscriptions = []
+        for estr in entity.extra_strings:
+            if estr.startswith(TAG):
+                subscriptions.append(estr[len(TAG):])
+
+    if len(subscriptions) > 0:
+        print "Subscriptions for %s:" % entity_name
+        print '\n'.join(subscriptions)
+
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be subscribe ID")
+    parser.add_option("-u", "--unsubscribe", action="store_true",
+                      dest="unsubscribe", default=False,
+                      help="Unsubscribe instead of subscribing.")
+    parser.add_option("-a", "--list-all", action="store_true",
+                      dest="list_all", default=False,
+                      help="List all subscribers (no ID argument, read only action).")
+    parser.add_option("-l", "--list", action="store_true",
+                      dest="list", default=False,
+                      help="List subscribers (read only action).")
+    parser.add_option("-s", "--subscriber", dest="subscriber",
+                      metavar="SUBSCRIBER",
+                      help="Email address of the subscriber (defaults to bugdir.user_id).")
+    parser.add_option("-S", "--servers", dest="servers", metavar="SERVERS",
+                      help="Servers from which you want notification.")
+    parser.add_option("-t", "--type", dest="types", metavar="TYPES",
+                      help="Types of changes you wish to be notified about.")
+    return parser
+
+longhelp="""
+ID can be either a bug id, or blank/"DIR", in which case it refers to the
+whole bug directory.
+
+SERVERS specifies the servers from which you would like to receive
+notification.  Multiple severs may be specified in a comma-separated
+list, or you can use "*" to match all servers (the default).  If you
+have not selected a server, it should politely refrain from notifying
+you of changes, although there is no way to guarantee this behavior.
+
+Available TYPES:
+  For bugs:
+%s
+  For DIR :
+%s
+
+For unsubscription, any listed SERVERS and TYPES are removed from your
+subscription.  Either the catch-all server "*" or type "%s" will
+remove SUBSCRIBER entirely from the specified ID.
+
+This command is intended for use primarily by public interfaces, since
+if you're just hacking away on your private repository, you'll known
+what's changed ;).  This command just (un)sets the appropriate
+subscriptions, and leaves it up to each interface to perform the
+notification.
+""" % (BUG_TYPE_ALL.string_tree(6), BUGDIR_TYPE_ALL.string_tree(6),
+       BUGDIR_TYPE_ALL)
+
+def help():
+    return get_parser().help_str() + longhelp
+
+# internal helper functions
+
+def _generate_string(subscriber, types, servers):
+    types = sorted([str(t) for t in types])
+    servers = sorted(servers)
+    return "%s%s\t%s\t%s" % (TAG,subscriber,",".join(types),",".join(servers))
+
+def _parse_string(string, type_root):
+    assert string.startswith(TAG), string
+    string = string[len(TAG):]
+    subscriber,types,servers = string.split("\t")
+    types = [type_from_name(name, type_root) for name in types.split(",")]
+    return (subscriber,types,servers.split(","))
+
+def _get_subscriber(extra_strings, subscriber, type_root):
+    for i,string in enumerate(extra_strings):
+        if string.startswith(TAG):
+            s,ts,srvs = _parse_string(string, type_root)
+            if s == subscriber:
+                return i,s,ts,srvs # match!
+    return None # no match
+
+# functions exposed to other modules
+
+def type_from_name(name, type_root, default=None, default_ok=False):
+    if name == str(type_root):
+        return type_root
+    for t in type_root.traverse():
+        if name == str(t):
+            return t
+    if default_ok:
+        return default
+    raise InvalidType(name, type_root)
+
+def subscribe(extra_strings, subscriber, types, servers, type_root):
+    args = _get_subscriber(extra_strings, subscriber, type_root)
+    if args == None: # no match
+        extra_strings.append(_generate_string(subscriber, types, servers))
+        return extra_strings
+    # Alter matched string
+    i,s,ts,srvs = args
+    for t in types:
+        if t not in ts:
+            ts.append(t)
+    # remove descendant types
+    all_ts = copy.copy(ts)
+    for t in all_ts:
+        for tt in all_ts:
+            if tt in ts and t.has_descendant(tt):
+                ts.remove(tt)
+    if "*" in servers+srvs:
+        srvs = ["*"]
+    else:
+        srvs = list(set(servers+srvs))
+    extra_strings[i] = _generate_string(subscriber, ts, srvs)
+    return extra_strings
+
+def unsubscribe(extra_strings, subscriber, types, servers, type_root):
+    args = _get_subscriber(extra_strings, subscriber, type_root)
+    if args == None: # no match
+        return extra_strings # pass
+    # Remove matched string
+    i,s,ts,srvs = args
+    all_ts = copy.copy(ts)
+    for t in types:
+        for tt in all_ts:
+            if tt in ts and t.has_descendant(tt):
+                ts.remove(tt)
+    if "*" in servers+srvs:
+        srvs = []
+    else:
+        for srv in servers:
+            if srv in srvs:
+                srvs.remove(srv)
+    if len(ts) == 0 or len(srvs) == 0:
+        extra_strings.pop(i)
+    else:
+        extra_strings[i] = _generate_string(subscriber, ts, srvs)
+    return extra_strings
+
+def get_subscribers(extra_strings, type, server, type_root,
+                    match_ancestor_types=False,
+                    match_descendant_types=False):
+    """
+    Set match_ancestor_types=True if you want to find eveyone who
+    cares about your particular type.
+
+    Set match_descendant_types=True if you want to find subscribers
+    who may only care about some subset of your type.  This is useful
+    for generating lists of all the subscribers in a given set of
+    extra_strings.
+
+    >>> def sgs(*args, **kwargs):
+    ...     return sorted(get_subscribers(*args, **kwargs))
+    >>> es = []
+    >>> es = subscribe(es, "John Doe <j@doe.com>", [BUGDIR_TYPE_ALL], ["a.com"], BUGDIR_TYPE_ALL)
+    >>> es = subscribe(es, "Jane Doe <J@doe.com>", [BUGDIR_TYPE_NEW], ["*"], BUGDIR_TYPE_ALL)
+    >>> sgs(es, BUGDIR_TYPE_ALL, "a.com", BUGDIR_TYPE_ALL)
+    ['John Doe <j@doe.com>']
+    >>> sgs(es, BUGDIR_TYPE_ALL, "a.com", BUGDIR_TYPE_ALL, match_descendant_types=True)
+    ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>']
+    >>> sgs(es, BUGDIR_TYPE_ALL, "b.net", BUGDIR_TYPE_ALL, match_descendant_types=True)
+    ['Jane Doe <J@doe.com>']
+    >>> sgs(es, BUGDIR_TYPE_NEW, "a.com", BUGDIR_TYPE_ALL)
+    ['Jane Doe <J@doe.com>']
+    >>> sgs(es, BUGDIR_TYPE_NEW, "a.com", BUGDIR_TYPE_ALL, match_ancestor_types=True)
+    ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>']
+    """
+    for string in extra_strings:
+        if not string.startswith(TAG):
+            continue
+        subscriber,types,servers = _parse_string(string, type_root)
+        type_match = False
+        if type in types:
+            type_match = True
+        if type_match == False and match_ancestor_types == True:
+            for t in types:
+                if t.has_descendant(type):
+                    type_match = True
+                    break
+        if type_match == False and match_descendant_types == True:
+            for t in types:
+                if type.has_descendant(t):
+                    type_match = True
+                    break
+        server_match = False
+        if server in servers or servers == ["*"] or server == "*":
+            server_match = True
+        if type_match == True and server_match == True:
+            yield subscriber
+
+def get_bugdir_subscribers(bugdir, server):
+    """
+    I have a bugdir.  Who cares about it, and what do they care about?
+    Returns a dict of dicts:
+      subscribers[user][id] = types
+    where id is either a bug.uuid (in the case of a bug subscription)
+    or "DIR" (in the case of a bugdir subscription).
+
+    Only checks bugs that are currently in memory, so you might want
+    to call bugdir.load_all_bugs() first.
+
+    >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+    >>> a = bd.bug_from_shortname("a")
+    >>> bd.extra_strings = subscribe(bd.extra_strings, "John Doe <j@doe.com>", [BUGDIR_TYPE_ALL], ["a.com"], BUGDIR_TYPE_ALL)
+    >>> bd.extra_strings = subscribe(bd.extra_strings, "Jane Doe <J@doe.com>", [BUGDIR_TYPE_NEW], ["*"], BUGDIR_TYPE_ALL)
+    >>> a.extra_strings = subscribe(a.extra_strings, "John Doe <j@doe.com>", [BUG_TYPE_ALL], ["a.com"], BUG_TYPE_ALL)
+    >>> subscribers = get_bugdir_subscribers(bd, "a.com")
+    >>> subscribers["Jane Doe <J@doe.com>"]["DIR"]
+    [<SubscriptionType: new>]
+    >>> subscribers["John Doe <j@doe.com>"]["DIR"]
+    [<SubscriptionType: all>]
+    >>> subscribers["John Doe <j@doe.com>"]["a"]
+    [<SubscriptionType: all>]
+    >>> get_bugdir_subscribers(bd, "b.net")
+    {'Jane Doe <J@doe.com>': {'DIR': [<SubscriptionType: new>]}}
+    >>> bd.cleanup()
+    """
+    subscribers = {}
+    for sub in get_subscribers(bugdir.extra_strings, BUGDIR_TYPE_ALL, server,
+                               BUGDIR_TYPE_ALL, match_descendant_types=True):
+        i,s,ts,srvs = _get_subscriber(bugdir.extra_strings,sub,BUGDIR_TYPE_ALL)
+        subscribers[sub] = {"DIR":ts}
+    for bug in bugdir:
+        for sub in get_subscribers(bug.extra_strings, BUG_TYPE_ALL, server,
+                                   BUG_TYPE_ALL, match_descendant_types=True):
+            i,s,ts,srvs = _get_subscriber(bug.extra_strings,sub,BUG_TYPE_ALL)
+            if sub in subscribers:
+                subscribers[sub][bug.uuid] = ts
+            else:
+                subscribers[sub] = {bug.uuid:ts}
+    return subscribers
index 216ffbcbe00e1b50fc1995c7cb6509a9f18cf33f..ecd853feb2d4c6128501084fc3f548679847d307 100644 (file)
@@ -18,34 +18,34 @@ from libbe import cmdutil, bugdir
 import os, copy
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> from libbe import utility
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> bd.set_sync_with_disk(True)
     >>> os.chdir(bd.root)
     >>> a = bd.bug_from_shortname("a")
     >>> print a.extra_strings
     []
-    >>> execute(["a", "GUI"], test=True)
+    >>> execute(["a", "GUI"], manipulate_encodings=False)
     Tags for a:
     GUI
     >>> bd._clear_bugs() # resync our copy of bug
     >>> a = bd.bug_from_shortname("a")
     >>> print a.extra_strings
     ['TAG:GUI']
-    >>> execute(["a", "later"], test=True)
+    >>> execute(["a", "later"], manipulate_encodings=False)
     Tags for a:
     GUI
     later
-    >>> execute(["a"], test=True)
+    >>> execute(["a"], manipulate_encodings=False)
     Tags for a:
     GUI
     later
-    >>> execute(["--list"], test=True)
+    >>> execute(["--list"], manipulate_encodings=False)
     GUI
     later
-    >>> execute(["a", "Alphabetically first"], test=True)
+    >>> execute(["a", "Alphabetically first"], manipulate_encodings=False)
     Tags for a:
     Alphabetically first
     GUI
@@ -57,15 +57,16 @@ def execute(args, test=False):
     >>> a.extra_strings = []
     >>> print a.extra_strings
     []
-    >>> execute(["a"], test=True)
+    >>> execute(["a"], manipulate_encodings=False)
     >>> bd._clear_bugs() # resync our copy of bug
     >>> a = bd.bug_from_shortname("a")
     >>> print a.extra_strings
     []
-    >>> execute(["a", "Alphabetically first"], test=True)
+    >>> execute(["a", "Alphabetically first"], manipulate_encodings=False)
     Tags for a:
     Alphabetically first
-    >>> execute(["--remove", "a", "Alphabetically first"], test=True)
+    >>> execute(["--remove", "a", "Alphabetically first"], manipulate_encodings=False)
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -78,7 +79,8 @@ def execute(args, test=False):
         help()
         raise cmdutil.UsageError("Too many arguments.")
     
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
     if options.list:
         bd.load_all_bugs()
         tags = []
@@ -92,7 +94,7 @@ def execute(args, test=False):
         if len(tags) > 0:
             print '\n'.join(tags)
         return
-    bug = bd.bug_from_shortname(args[0])
+    bug = cmdutil.bug_from_shortname(bd, args[0])
     if len(args) == 2:
         given_tag = args[1]
         estrs = bug.extra_strings
index 527b16a4984a2bc989ae406f2166da24321ff538..7e41451dc9c412cb528f7e70736d5fb299ea000a 100644 (file)
 from libbe import cmdutil, bugdir
 __desc__ = __doc__
 
-def execute(args, test=False):
+def execute(args, manipulate_encodings=True):
     """
     >>> import os
-    >>> bd = bugdir.simple_bug_dir()
+    >>> bd = bugdir.SimpleBugDir()
     >>> os.chdir(bd.root)
-    >>> execute(["a"], test=True)
+    >>> execute(["a"], manipulate_encodings=False)
     No target assigned.
-    >>> execute(["a", "tomorrow"], test=True)
-    >>> execute(["a"], test=True)
+    >>> execute(["a", "tomorrow"], manipulate_encodings=False)
+    >>> execute(["a"], manipulate_encodings=False)
     tomorrow
-    >>> execute(["--list"], test=True)
+    >>> execute(["--list"], manipulate_encodings=False)
     tomorrow
-    >>> execute(["a", "none"], test=True)
-    >>> execute(["a"], test=True)
+    >>> execute(["a", "none"], manipulate_encodings=False)
+    >>> execute(["a"], manipulate_encodings=False)
     No target assigned.
+    >>> bd.cleanup()
     """
     parser = get_parser()
     options, args = parser.parse_args(args)
@@ -46,14 +47,15 @@ def execute(args, test=False):
     if len(args) not in (1, 2):
         if not (options.list == True and len(args) == 0):
             raise cmdutil.UsageError
-    bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test)
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
     if options.list:
         ts = set([bd.bug_from_uuid(bug).target for bug in bd.list_uuids()])
         for target in sorted(ts):
             if target and isinstance(target,str):
                 print target
         return
-    bug = bd.bug_from_shortname(args[0])
+    bug = cmdutil.bug_from_shortname(bd, args[0])
     if len(args) == 1:
         if bug.target is None:
             print "No target assigned."
diff --git a/interfaces/README b/interfaces/README
new file mode 100644 (file)
index 0000000..4d74580
--- /dev/null
@@ -0,0 +1,34 @@
+Removing spam commits from the history
+======================================
+
+arch bzr darcs git hg none
+
+In the case that some spam or inappropriate comment makes its way
+through you interface, you can remove the offending commit XYZ with:
+
+  If the offending commit is the last commit:
+
+    arch:  
+    bzr:   bzr uncommit && bzr revert
+    darcs: darcs obliterate --last=1
+    git:   git reset --hard HEAD^
+    hg:    hg rollback && hg revert
+
+  If the offending commit is not the last commit:
+
+    arch:  
+    bzr:   bzr rebase -r <XYZ+1>..-1 --onto before:XYZ .
+      (requires bzr-rebase plugin, note, you have to increment XYZ by
+      hand for <XYZ+1>, because bzr does not support "after:XYZ".)
+    darcs: darcs obliterate --matches 'name XYZ'
+    git:   git rebase --onto XYZ~1 XYZ
+    hg:     -not-supported-
+      (From http://hgbook.red-bean.com/read/finding-and-fixing-mistakes.html#id394667
+      "Mercurial also does not provide a way to make a file or
+      changeset completely disappear from history, because there is no
+      way to enforce its disappearance")
+
+Note that all of these _change_the_repo_history_, so only do this on
+your interface-specific repo before it interacts with any other repo.
+Otherwise, you'll have to survive by cherry-picking only the good
+commits.
diff --git a/interfaces/email/interactive/README b/interfaces/email/interactive/README
new file mode 100644 (file)
index 0000000..79ef9a9
--- /dev/null
@@ -0,0 +1,145 @@
+Overview
+========
+
+The interactive email interface to Bugs Everywhere (BE) attempts to
+provide a Debian-bug-tracking-system-style interface to a BE
+repository.  Users can mail in bug reports, comments, or control
+requests, which will be committed to the served repository.
+Developers can then pull the changes they approve of from the served
+repository into their other repositories and push updates back onto
+the served repository.
+
+For details about the Debian bug tracking system that inspired this
+interface, see http://www.debian.org/Bugs .
+
+Architecture
+============
+
+In order to reduce setup costs, the entire interface can piggyback on
+an existing email address, although from a security standpoint it's
+probably best to create a dedicated user.  Incoming email is filtered
+by procmail, with matching emails being piped into be-handle-mail for
+execution.
+
+Once be-handle-mail receives the email, the parsing method is selected
+according to the subject tag that procmail used grab the email in the
+first place.  There are three parsing styles:
+    Style                 Subject
+    creating bugs         [be-bug:submit] new bug summary
+    commenting on bugs    [be-bug:<bug-id>] commit message
+    control               [be-bug] commit message
+These are analogous to submit@bugs.debian.org, nnn@bugs.debian.org,
+and control@bugs.debian.org respectively.
+
+Creating bugs
+=============
+
+This interface creates a bug whose summary is given by the email's
+post-tag subject.  The body of the email must begin with a
+pseudo-header containing at least the "Version" field.  Anything after
+the pseudo-header and before a line starting with '--' is, if present,
+attached as the bug's first comment.
+
+    From jdoe@example.com Fri Apr 18 12:00:00 2008
+    From: John Doe <jdoe@example.com>
+    Date: Fri, 18 Apr 2008 12:00:00 +0000
+    Content-Type: text/plain; charset=UTF-8
+    Content-Transfer-Encoding: 8bit
+    Subject: [be-bug:submit] Need tests for the email interface.
+    
+    Version: XYZ
+    Severity: minor
+    
+    Someone should write up a series of test emails to send into
+    be-handle mail so we can test changes quickly without having to
+    use procmail.
+    
+    --
+    Goofy tagline not included.
+
+Available pseudo-headers are Version, Reporter, Assign, Depend,
+Severity, Status, Tag, and Target.
+
+Commenting on bugs
+==================
+
+This interface appends a comment to the bug specified in the subject
+tag.  The the first non-multipart body is attached with the
+appropriate content-type.  In the case of "text/plain" contents,
+anything following a line starting with '--' is stripped.
+
+    From jdoe@example.com Fri Apr 18 12:00:00 2008
+    From: John Doe <jdoe@example.com>
+    Date: Fri, 18 Apr 2008 12:00:00 +0000
+    Content-Type: text/plain; charset=UTF-8
+    Content-Transfer-Encoding: 8bit
+    Subject: [be-bug:XYZ] Isolated problem in baz()
+    
+    Finally tracked it down to the bar() call.  Some sort of
+    string<->unicode conversion problem.  Solution ideas?
+    
+    --
+    Goofy tagline not included.
+
+Controlling bugs
+================
+
+This interface consists of a list of allowed be commands, with one
+command per line.  Blank lines and lines beginning with '#' are
+ignored, as well anything following a line starting with '--'.  All
+the listed commands are executed in order and their output returned.
+The commands are split into arguments with the POSIX-compliant
+shlex.split().
+
+    From jdoe@example.com Fri Apr 18 12:00:00 2008
+    From: John Doe <jdoe@example.com>
+    Date: Fri, 18 Apr 2008 12:00:00 +0000
+    Content-Type: text/plain; charset=UTF-8
+    Content-Transfer-Encoding: 8bit
+    Subject: [be-bug] I'll handle XYZ by release 1.2.3
+    
+    assign XYZ "John Doe <jdoe@example.com>"
+    status XYZ assigned
+    severity XYZ critical
+    target XYZ 1.2.3
+    
+    --
+    Goofy tagline ignored.
+
+Example emails
+==============
+
+Take a look at my interfaces/email/interactive/examples for some
+more examples.
+
+Procmail rules
+==============
+
+The file _procmailrc as it stands is fairly appropriate for as a
+dedicated user's ~/.procmailrc.  It forwards matching mail to
+be-handle-mail, which should be installed somewhere in the user's
+path.  All non-matching mail is dumped into /dev/null.  Everything
+procmail does will be logged to ~/be-mail/procmail.log.
+
+If you're piggybacking the interface on top of an existing account,
+you probably only need to add the be-handle-mail stanza to your
+existing ~/.procmailrc, since you will still want to receive non-bug
+emails.
+
+Note that you will probably have to add a
+  --be-dir /path/to/served/repository
+option to the be-handle-mail invocation so it knows what repository to
+serve.
+
+Multiple repositories may be served by the same email address by adding
+multiple be-handle-mail stanzas, each matching a different tag, for
+example the "[be-bug" portion of the stanza could be "[projectX-bug",
+"[projectY-bug", etc.  If you change the base tag, be sure to add a
+  --tag-base "projectX-bug"
+or equivalent to your be-handle-mail invocation.
+
+Testing
+=======
+
+Send test emails in to be-handle-mail with something like
+  cat examples/blank | ./be-handle-mail -o -l - -a
diff --git a/interfaces/email/interactive/_procmailrc b/interfaces/email/interactive/_procmailrc
new file mode 100644 (file)
index 0000000..d42c0cf
--- /dev/null
@@ -0,0 +1,22 @@
+# .procmailrc
+#
+# see man procmail, procmailrc, and procmailex
+#
+# If you already have a ~/.procmailrc file, you probably only need to
+# insert the bug-email grabbing stanza in your ~/.procmailrc.
+#
+# This file is released to the Public Domain.
+
+MAILDIR=$HOME/be-mail
+LOGFILE=$MAILDIR/procmail.log
+
+# Grab all incoming bug emails (but not replies).  This rule eats
+# matching emails (i.e. no further procmail processing).
+:0
+* ^Subject: \[be-bug
+* !^Subject:.*\[be-bug].*Re:
+| be-handle-mail
+
+# Drop everything else
+:0
+/dev/null
diff --git a/interfaces/email/interactive/be-handle-mail b/interfaces/email/interactive/be-handle-mail
new file mode 100644 (file)
index 0000000..fa80698
--- /dev/null
@@ -0,0 +1,950 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""
+Provide and email interface to the distributed bugtracker Bugs
+Everywhere.  Recieves incoming email via procmail.  Provides an
+interface similar to the Debian Bug Tracker.  There are currently
+three distinct email types: submits, comments, and controls.  The
+email types are differentiated by tags in the email subject.  See
+SUBJECT_TAG* for the current values.
+
+Submit emails create a bug (and optionally add some intitial
+comments).  The post-tag subject is used as the bug summary, and the
+email body is parsed for a pseudo-header.  Any text after the
+psuedo-header but before a possible line starting with BREAK is added
+as the initial bug comment.
+
+Comment emails add comments to a bug.  The first non-multipart portion
+of the email is used as the comment body.  If that portion has a
+"text/plain" type, any text after and including a possible line
+starting with BREAK is stripped to avoid lots of taglines cluttering
+up the repository.
+
+Control emails preform any allowed BE commands.  The first
+non-multipart portion of the email is used as the comment body.  If
+that portion has a "text/plain" type, any text after and including a
+possible line starting with BREAK is stripped.  Each pre-BREAK line of
+the portion should be a valid BE command, with the initial "be"
+omitted, e.g. "be status XYZ fixed" --> "status XYZ fixed".
+
+Any changes made to the repository are commited after the email is
+executed, with the email's post-tag subject as the commit message.
+"""
+
+import codecs
+import StringIO as StringIO
+import email
+from email.mime.multipart import MIMEMultipart
+import email.utils
+import os
+import os.path
+import re
+import shlex
+import sys
+import time
+import traceback
+import doctest
+import unittest
+
+from becommands import subscribe
+import libbe.cmdutil, libbe.encoding, libbe.utility, libbe.diff, \
+    libbe.bugdir, libbe.bug, libbe.comment
+import send_pgp_mime
+
+THIS_SERVER = u"thor.physics.drexel.edu"
+THIS_ADDRESS = u"BE Bugs <wking@thor.physics.drexel.edu>"
+
+_THIS_DIR = os.path.abspath(os.path.dirname(__file__))
+BE_DIR = _THIS_DIR
+LOGPATH = os.path.join(_THIS_DIR, u"be-handle-mail.log")
+LOGFILE = None
+
+# Tag strings generated by generate_global_tags()
+SUBJECT_TAG_BASE = u"be-bug"
+SUBJECT_TAG_RESPONSE = None
+SUBJECT_TAG_START = None
+SUBJECT_TAG_NEW = None
+SUBJECT_TAG_COMMENT = None
+SUBJECT_TAG_CONTROL = None
+
+BREAK = u"--"
+NEW_REQUIRED_PSEUDOHEADERS = [u"Version"]
+NEW_OPTIONAL_PSEUDOHEADERS = [u"Reporter", u"Assign", u"Depend", u"Severity",
+                              u"Status", u"Tag", u"Target",
+                              u"Confirm", u"Subscribe"]
+CONTROL_COMMENT = u"#"
+ALLOWED_COMMANDS = [u"assign", u"comment", u"commit", u"depend", u"help",
+                    u"list", u"merge", u"new", u"open", u"severity", u"show",
+                    u"status", u"subscribe", u"tag", u"target"]
+
+AUTOCOMMIT = True
+
+libbe.encoding.ENCODING = u"utf-8" # force default encoding
+ENCODING = libbe.encoding.get_encoding()
+
+class InvalidEmail (ValueError):
+    def __init__(self, msg, message):
+        ValueError.__init__(self, message)
+        self.msg = msg
+    def response(self):
+        header = self.msg.response_header
+        body = [u"Error processing email:\n",
+                self.response_body(), u""]
+        response_generator = \
+            send_pgp_mime.PGPMimeMessageFactory(u"\n".join(body))
+        response = MIMEMultipart()
+        response.attach(response_generator.plain())
+        response.attach(self.msg.msg)
+        ret = send_pgp_mime.attach_root(header, response)
+        return ret
+    def response_body(self):
+        err_text = [unicode(self)]
+        return u"\n".join(err_text)
+
+class InvalidSubject (InvalidEmail):
+    def __init__(self, msg, message=None):
+        if message == None:
+            message = u"Invalid subject"
+        InvalidEmail.__init__(self, msg, message)
+    def response_body(self):
+        err_text = u"\n".join([unicode(self), u"",
+                               u"full subject was:",
+                               self.msg.subject()])
+        return err_text
+
+class InvalidPseudoHeader (InvalidEmail):
+    def response_body(self):
+        err_text = [u"Invalid pseudo-header:\n",
+                    unicode(self)]
+        return u"\n".join(err_text)
+
+class InvalidCommand (InvalidEmail):
+    def __init__(self, msg, command, message=None):
+        bigmessage = u"Invalid execution command '%s'" % command
+        if message != None:
+            bigmessage += u"\n%s" % message
+        InvalidEmail.__init__(self, msg, bigmessage)
+        self.command = command
+
+class InvalidOption (InvalidCommand):
+    def __init__(self, msg, option, message=None):
+        bigmessage = u"Invalid option '%s'" % (option)
+        if message != None:
+            bigmessage += u"\n%s" % message
+        InvalidCommand.__init__(self, msg, info, command, bigmessage)
+        self.option = option
+
+class NotificationFailed (Exception):
+    def __init__(self, msg):
+        bigmessage = "Notification failed: %s" % msg
+        Exception.__init__(self, bigmessage)
+        self.short_msg = msg
+
+class ID (object):
+    """
+    Sometimes you want to reference the output of a command that
+    hasn't been executed yet.  ID is there for situations like
+    > a = Command(msg, "new", ["create a bug"])
+    > b = Command(msg, "comment", [ID(a), "and comment on it"])
+    """
+    def __init__(self, command):
+        self.command = command
+    def extract_id(self):
+        if hasattr(self, "cached_id"):
+            return self._cached_id
+        assert self.command.ret == 0, self.command.ret
+        if self.command.command == u"new":
+            regexp = re.compile(u"Created bug with ID (.*)")
+        else:
+            raise NotImplementedError, self.command.command
+        match = regexp.match(self.command.stdout)
+        assert len(match.groups()) == 1, str(match.groups())
+        self._cached_id = match.group(1)
+        return self._cached_id
+    def __str__(self):
+        if self.command.ret != 0:
+            return "<id for %s>" % repr(self.command)
+        return "<id %s>" % self.extract_id()
+
+class Command (object):
+    """
+    A becommands command wrapper.
+    Doesn't validate input, so do that before initializing.
+
+    Initialize with
+      Command(msg, command, args=None, stdin=None)
+    where
+      msg:     the Message instance prompting this command
+      command: name of becommand to execute, e.g. "new"
+      args:    list of arguments to pass to the command
+      stdin:   if non-null, a string to pipe into the command's stdin
+    """
+    def __init__(self, msg, command, args=None, stdin=None):
+        self.msg = msg
+        self.command = command
+        if args == None:
+            self.args = []
+        else:
+            self.args = args
+        self.stdin = stdin
+        self.ret = None
+        self.stdout = None
+        self.stderr = None
+        self.err = None
+    def __str__(self):
+        return "<command: %s %s>" % (self.command, " ".join([str(s) for s in self.args]))
+    def normalize_args(self):
+        """
+        Expand any ID placeholders in self.args.
+        """
+        for i,arg in enumerate(self.args):
+            if isinstance(arg, ID):
+                self.args[i] = arg.extract_id()
+    def run(self):
+        """
+        Attempt to execute the command whose info is given in the dictionary
+        info.  Returns the exit code, stdout, and stderr produced by the
+        command.
+        """
+        if self.command in [None, u""]: # don't accept blank commands
+            raise InvalidCommand(self.msg, self, "Blank")
+        elif self.command not in ALLOWED_COMMANDS:
+            raise InvalidCommand(self.msg, self, "Not allowed")
+        assert self.ret == None, u"running %s twice!" % unicode(self)
+        self.normalize_args()
+        # set stdin and catch stdout and stderr
+        if self.stdin != None:
+            orig_stdin = sys.stdin
+            sys.stdin = StringIO.StringIO(self.stdin)
+        new_stdout = codecs.getwriter(ENCODING)(StringIO.StringIO())
+        new_stderr = codecs.getwriter(ENCODING)(StringIO.StringIO())
+        orig_stdout = sys.stdout
+        orig_stderr = sys.stderr
+        sys.stdout = new_stdout
+        sys.stderr = new_stderr
+        # run the command
+        os.chdir(BE_DIR)
+        try:
+            self.ret = libbe.cmdutil.execute(self.command, self.args,
+                                             manipulate_encodings=False)
+        except libbe.cmdutil.GetHelp:
+            print libbe.cmdutil.help(command)
+        except libbe.cmdutil.GetCompletions:
+            self.err = InvalidOption(self.msg, self.command, u"--complete")
+        except libbe.cmdutil.UsageError, e:
+            self.err = InvalidCommand(self.msg, self,
+                                      "%s\n%s" % (type(e), unicode(e)))
+        except libbe.cmdutil.UserError, e:
+            self.err = InvalidCommand(self.msg, self,
+                                      "%s\n%s" % (type(e), unicode(e)))
+        # restore stdin, stdout, and stderr
+        if self.stdin != None:
+            sys.stdin = orig_stdin
+        sys.stdout.flush()
+        sys.stderr.flush()
+        sys.stdout = orig_stdout
+        sys.stderr = orig_stderr
+        self.stdout = codecs.decode(new_stdout.getvalue(), ENCODING)
+        self.stderr = codecs.decode(new_stderr.getvalue(), ENCODING)
+        if self.err != None:
+            raise self.err
+        return (self.ret, self.stdout, self.stderr)
+    def response_msg(self):
+        if self.ret == None: self.ret = -1
+        response_body = [u"Results of running: (exit code %d)" % self.ret,
+                         u"  %s %s" % (self.command, u" ".join(self.args))]
+        if self.stdout != None and len(self.stdout) > 0:
+            response_body.extend([u"", u"stdout:", u"", self.stdout])
+        if self.stderr != None and len(self.stderr) > 0:
+            response_body.extend([u"", u"stderr:", u"", self.stderr])
+        response_body.append(u"") # trailing endline
+        response_generator = \
+            send_pgp_mime.PGPMimeMessageFactory(u"\n".join(response_body))
+        return response_generator.plain()
+
+class DiffTree (libbe.diff.DiffTree):
+    """
+    In order to avoid tons of tiny MIMEText attachments, bug-level
+    nodes set .add_child_text=True (in .join()), which is propogated
+    on to their descendents.  Instead of creating their own
+    attachement, each of these descendents appends his data_part to
+    the end of the bug-level MIMEText attachment.
+
+    For the example tree in the libbe.diff.Diff unittests:
+      bugdir
+      bugdir/settings
+      bugdir/bugs
+      bugdir/bugs/new
+      bugdir/bugs/new/c          <- sets .add_child_text
+      bugdir/bugs/rem
+      bugdir/bugs/rem/b          <- sets .add_child_text
+      bugdir/bugs/mod
+      bugdir/bugs/mod/a          <- sets .add_child_text
+      bugdir/bugs/mod/a/settings
+      bugdir/bugs/mod/a/comments
+      bugdir/bugs/mod/a/comments/new
+      bugdir/bugs/mod/a/comments/new/acom
+      bugdir/bugs/mod/a/comments/rem
+      bugdir/bugs/mod/a/comments/mod
+    """
+    def report_or_none(self):
+        report = self.report()
+        payload = report.get_payload()
+        if payload == None or len(payload) == 0:
+            return None
+        return report
+    def report_string(self):
+        report = self.report_or_none()
+        if report == None:
+            return "No changes"
+        else:
+            return send_pgp_mime.flatten(self.report(), to_unicode=True)
+    def make_root(self):
+        return MIMEMultipart()
+    def join(self, root, parent, data_part):
+        if hasattr(parent, "attach_child_text"):
+            self.attach_child_text = True
+            if data_part != None:
+                send_pgp_mime.append_text(parent.data_mime_part, u"\n\n%s" % (data_part))
+            self.data_mime_part = parent.data_mime_part
+        else:
+            self.data_mime_part = None
+            if data_part != None:
+                self.data_mime_part = send_pgp_mime.encodedMIMEText(data_part)
+            if parent != None and parent.name in [u"new", u"rem", u"mod"]:
+                self.attach_child_text = True
+                if data_part == None: # make blank data_mime_part for children's appends
+                    self.data_mime_part = send_pgp_mime.encodedMIMEText(u"")
+            if self.data_mime_part != None:
+                self.data_mime_part[u"Content-Description"] = self.name
+                root.attach(self.data_mime_part)
+    def data_part(self, depth, indent=False):
+        return libbe.diff.DiffTree.data_part(self, depth, indent=indent)
+
+class Diff (libbe.diff.Diff):
+    def bug_add_string(self, bug):
+        return bug.string(show_comments=True)
+    def _comment_summary_string(self, comment):
+        return comment.string()
+    def comment_add_string(self, comment):
+        return self._comment_summary_string(comment)
+    def comment_rem_string(self, comment):
+        return self._comment_summary_string(comment)
+
+class Message (object):
+    def __init__(self, email_text=None, disable_parsing=False):
+        if disable_parsing == False:
+            self.text = email_text
+            p=email.Parser.Parser()
+            self.msg=p.parsestr(self.text)
+            if LOGFILE != None:
+                LOGFILE.write(u"handling %s\n" % self.author_addr())
+                LOGFILE.write(u"\n%s\n\n" % self.text)
+        self.confirm = True # enable/disable confirmation email
+    def _yes_no(self, boolean):
+        if boolean == True:
+            return "yes"
+        return "no"
+    def author_tuple(self):
+        """
+        Extract and normalize the sender's email address.  Returns a
+        (name, email) tuple.
+        """
+        if not hasattr(self, "author_tuple_cache"):
+            self._author_tuple_cache = \
+                send_pgp_mime.source_email(self.msg, return_realname=True)
+        return self._author_tuple_cache
+    def author_addr(self):
+        return email.utils.formataddr(self.author_tuple())
+    def author_name(self):
+        return self.author_tuple()[0]
+    def author_email(self):
+        return self.author_tuple()[1]
+    def default_msg_attribute_access(self, attr_name, default=None):
+        if attr_name in self.msg:
+            return self.msg[attr_name]
+        return default
+    def message_id(self, default=None):
+        return self.default_msg_attribute_access("message-id", default=default)
+    def subject(self):
+        if "subject" not in self.msg:
+            raise InvalidSubject(self, u"Email must contain a subject")
+        return self.msg["subject"]
+    def _split_subject(self):
+        """
+        Returns (tag, subject), with missing values replaced by None.
+        """
+        if hasattr(self, "_split_subject_cache"):
+            return self._split_subject_cache
+        args = self.subject().split(u"]",1)
+        if len(args) < 1:
+            self._split_subject_cache = (None, None)
+        elif len(args) < 2:
+            self._split_subject_cache = (args[0]+u"]", None)
+        else:
+            self._split_subject_cache = (args[0]+u"]", args[1].strip())
+        return self._split_subject_cache
+    def _subject_tag_type(self):
+        """
+        Parse subject tag, return (type, value), where type is one of
+        None, "new", "comment", or "control"; and value is None except
+        in the case of "comment", in which case it's the bug
+        ID/shortname.
+        """
+        tag,subject = self._split_subject()
+        type = None
+        value = None
+        if tag == SUBJECT_TAG_NEW:
+            type = u"new"
+        elif tag == SUBJECT_TAG_CONTROL:
+            type = u"control"
+        else:
+            match = SUBJECT_TAG_COMMENT.match(tag)
+            if len(match.groups()) == 1:
+                type = u"comment"
+                value = match.group(1)
+        return (type, value)
+    def validate_subject(self):
+        """
+        Validate the subject line.
+        """
+        tag,subject = self._split_subject()
+        if not tag.startswith(SUBJECT_TAG_START):
+            raise InvalidSubject(
+                self, u"Subject must start with '%s'" % SUBJECT_TAG_START)
+        tag_type,value = self._subject_tag_type()
+        if tag_type == None:
+            raise InvalidSubject(self, u"Invalid tag '%s'" % tag)
+        elif tag_type == u"new" and len(subject) == 0:
+            raise InvalidSubject(self, u"Cannot create a bug with blank title")
+        elif tag_type == u"comment" and len(value) == 0:
+            raise InvalidSubject(self, u"Must specify a bug ID to comment")
+    def _get_bodies_and_mime_types(self):
+        """
+        Traverse the email message returning (body, mime_type) for
+        each non-mulitpart portion of the message.
+        """
+        msg_charset = self.msg.get_content_charset(ENCODING).lower()
+        for part in self.msg.walk():
+            if part.is_multipart():
+                continue
+            body,mime_type=(part.get_payload(decode=True),part.get_content_type())
+            charset = part.get_content_charset(msg_charset).lower()
+            if mime_type.startswith("text/"):
+                body = unicode(body, charset) # convert text types to unicode
+            yield (body, mime_type)
+    def _parse_body_pseudoheaders(self, body, required, optional,
+                                  dictionary=None):
+        """
+        Grab any pseudo-headers from the beginning of body.  Raise
+        InvalidPseudoHeader on errors.  Returns the body text after
+        the pseudo-header and a dictionary of set options.  If you
+        like, you can initialize the dictionary with some defaults
+        and pass your initialized dict in as dictionary.
+        """
+        if dictionary == None:
+            dictionary = {}
+        body_lines = body.splitlines()
+        all = required+optional
+        for i,line in enumerate(body_lines):
+            line = line.strip()
+            if len(line) == 0:
+                break
+            if ":" not in line:
+                raise InvalidPseudoheader(self, line)
+            key,value = line.split(":", 1)
+            value = value.strip()
+            if key not in all:
+                raise InvalidPseudoHeader(self, key)
+            if len(value) == 0:
+                raise InvalidEmail(
+                    self, u"Blank value for: %s" % key)
+            dictionary[key] = value
+        missing = []
+        for key in required:
+            if key not in dictionary:
+                missing.append(key)
+        if len(missing) > 0:
+            raise InvalidPseudoHeader(self,
+                                      u"Missing required pseudo-headers:\n%s"
+                                      % u", ".join(missing))
+        remaining_body = u"\n".join(body_lines[i:]).strip()
+        return (remaining_body, dictionary)
+    def _strip_footer(self, body):
+        body_lines = body.splitlines()
+        for i,line in enumerate(body_lines):
+            if line.startswith(BREAK):
+                break
+            i += 1 # increment past the current valid line.
+        return u"\n".join(body_lines[:i]).strip()
+    def parse(self):
+        """
+        Parse the commands given in the email.  Raises assorted
+        subclasses of InvalidEmail in the case of invalid messages,
+        otherwise returns a list of suggested commands to run.
+        """
+        self.validate_subject()
+        tag_type,value = self._subject_tag_type()
+        if tag_type == u"new":
+            commands = self.parse_new()
+        elif tag_type == u"comment":
+            commands = self.parse_comment(value)
+        elif tag_type == u"control":
+            commands = self.parse_control()
+        else:
+            raise Exception, u"Unrecognized tag type '%s'" % tag_type
+        return commands
+    def parse_new(self):
+        command = u"new"
+        tag,subject = self._split_subject()
+        summary = subject
+        options = {u"Reporter": self.author_addr(),
+                   u"Confirm": self._yes_no(self.confirm),
+                   u"Subscribe": "no",
+                   }
+        body,mime_type = list(self._get_bodies_and_mime_types())[0]
+        comment_body,options = \
+            self._parse_body_pseudoheaders(body,
+                                           NEW_REQUIRED_PSEUDOHEADERS,
+                                           NEW_OPTIONAL_PSEUDOHEADERS,
+                                           options)
+        if options[u"Confirm"].lower() == "no":
+            self.confirm = False
+        if options[u"Subscribe"].lower() == "yes" and self.confirm == True:
+            # respond with the subscription format rather than the
+            # normal command-output format, because the subscription
+            # format is more user-friendly.
+            self.confirm = False
+        args = [u"--reporter", options[u"Reporter"]]
+        args.append(summary)
+        commands = [Command(self, command, args)]
+        id = ID(commands[0])
+        comment_body = self._strip_footer(comment_body)
+        if len(comment_body) > 0:
+            command = u"comment"
+            comment = u"Version: %s\n\n"%options[u"Version"] + comment_body
+            args = [u"--author", self.author_addr(),
+                    u"--alt-id", self.message_id(),
+                    u"--content-type", mime_type]
+            args.append(id)
+            args.append(u"-")
+            commands.append(Command(self, u"comment", args, stdin=comment))
+        for key,value in options.items():
+            if key in [u"Version", u"Reporter", u"Confirm"]:
+                continue # we've already handled these options
+            command = key.lower()
+            args = [id, value]
+            if key == u"Subscribe":
+                if value.lower() != "yes":
+                    continue
+                args = ["--subscriber", self.author_addr(), id]
+            commands.append(Command(self, command, args))
+        return commands
+    def parse_comment(self, bug_uuid):
+        command = u"comment"
+        bug_id = bug_uuid
+        author = self.author_addr()
+        alt_id = self.message_id()
+        body,mime_type = list(self._get_bodies_and_mime_types())[0]
+        if mime_type == "text/plain":
+            body = self._strip_footer(body)
+        content_type = mime_type
+        args = [u"--author", author, u"--alt-id", alt_id,
+                u"--content-type", content_type, bug_id, u"-"]
+        commands = [Command(self, command, args, stdin=body)]
+        return commands
+    def parse_control(self):
+        body,mime_type = list(self._get_bodies_and_mime_types())[0]
+        commands = []
+        for line in body.splitlines():
+            line = line.strip()
+            if line.startswith(CONTROL_COMMENT) or len(line) == 0:
+                continue
+            if line.startswith(BREAK):
+                break
+            fields = shlex.split(line)
+            command,args = (fields[0], fields[1:])
+            commands.append(Command(self, command, args))
+        if len(commands) == 0:
+            raise InvalidEmail(self, u"No commands in control email.")
+        return commands
+    def run(self):
+        self._begin_response()
+        commands = self.parse()
+        try:
+            for command in commands:
+                command.run()
+                self._add_response(command.response_msg())
+        finally:
+            if AUTOCOMMIT == True:
+                tag,subject = self._split_subject()
+                self.commit_command = Command(self, "commit", [subject])
+                self.commit_command.run()
+                if LOGFILE != None:
+                    LOGFILE.write(u"Autocommit:\n%s\n\n" %
+                      send_pgp_mime.flatten(self.commit_command.response_msg(),
+                                            to_unicode=True))
+    def _begin_response(self):
+        tag,subject = self._split_subject()
+        response_header = [u"From: %s" % THIS_ADDRESS,
+                           u"To: %s" % self.author_addr(),
+                           u"Date: %s" % libbe.utility.time_to_str(time.time()),
+                           u"Subject: %s Re: %s"%(SUBJECT_TAG_RESPONSE,subject)
+                           ]
+        if self.message_id() != None:
+            response_header.append(u"In-reply-to: %s" % self.message_id())
+        self.response_header = \
+            send_pgp_mime.header_from_text(text=u"\n".join(response_header))
+        self._response_messages = []
+    def _add_response(self, response_message):
+        self._response_messages.append(response_message)
+    def response_email(self):
+        assert len(self._response_messages) > 0
+        if len(self._response_messages) == 1:
+            response_body = self._response_messages[0]
+        else:
+            response_body = MIMEMultipart()
+            for message in self._response_messages:
+                response_body.attach(message)
+        return send_pgp_mime.attach_root(self.response_header, response_body)
+    def subscriber_emails(self, previous_revision=None):
+        if previous_revision == None:
+            if AUTOCOMMIT != True: # no way to tell what's changed
+                raise NotificationFailed("Autocommit dissabled")
+            if len(self._response_messages) == 0:
+                raise NotificationFailed("Initial email failed.")
+            if self.commit_command.ret != 0:
+                # commit failed.  Error already logged.
+                raise NotificationFailed("Commit failed")
+
+        # read only bugdir.
+        bd = libbe.bugdir.BugDir(from_disk=True,
+                                 manipulate_encodings=False)
+        if bd.vcs.versioned == False: # no way to tell what's changed
+            raise NotificationFailed("Not versioned")
+
+        bd.load_all_bugs()
+        subscribers = subscribe.get_bugdir_subscribers(bd, THIS_SERVER)
+
+        if len(subscribers) == 0:
+            return [] 
+
+        before_bd, after_bd = self._get_before_and_after_bugdirs(bd, previous_revision)
+        diff = Diff(before_bd, after_bd)
+        diff_tree = diff.report_tree(diff_tree=DiffTree)
+        bug_index = {}
+        for child in diff_tree.child_by_path("/bugs/new"):
+            bug_index[child.name] = ("added", child)
+        for child in diff_tree.child_by_path("/bugs/mod"):
+            bug_index[child.name] = ("modified", child)
+        for child in diff_tree.child_by_path("/bugs/rem"):
+            bug_index[child.name] = ("removed", child)
+        header = self._subscriber_header(bd, previous_revision)
+
+        emails = []
+        for subscriber,subscriptions in subscribers.items():
+            header.replace_header("to", subscriber)
+            parts = []
+            if "DIR" in subscriptions: # make sure we check the DIR level first
+                ordered_subscriptions = [("DIR", subscriptions.pop("DIR"))]
+            else:
+                ordered_subscriptions = []
+            ordered_subscriptions.extend(subscriptions.items())
+            for id,types in ordered_subscriptions:
+                if id == "DIR":
+                    if subscribe.BUGDIR_TYPE_ALL in types:
+                        parts.append(diff_tree.report_or_none())
+                        break # we've attached everything, so stop checking.
+                    if subscribe.BUGDIR_TYPE_NEW in types:
+                        new = diff_tree.child_by_path("/bugs/new")
+                        parts.append(new.report_or_none())
+                    continue # move on to next id
+                # if we get this far, id refers to a bug.
+                assert types == [subscribe.BUG_TYPE_ALL], types
+                if id not in bug_index:
+                    continue # no changes here, move on to next id
+                type,bug_root = bug_index[id]
+                if type == "added" \
+                        and "DIR" in subscriptions \
+                        and subscriptions["DIR"] == subscribe.BUGDIR_TYPE_NEW:
+                    # this info already attached at the DIR level
+                    continue # move on to next id
+                parts.append(bug_root.report_or_none())
+            parts = [p for p in parts if p != None]
+            if len(parts) == 0:
+                continue # no email to this subscriber
+            elif len(parts) == 1:
+                root = parts[0]
+            else: # join subscription parts into a single body
+                root = MIMEMultipart()
+                root[u"Content-Description"] = u"Multiple subscription trees."
+                for part in parts:
+                    root.attach(part)
+            emails.append(send_pgp_mime.attach_root(header, root))
+            if LOGFILE != None:
+                LOGFILE.write(u"Preparing to notify %s of changes\n" % subscriber)
+        return emails
+    def _get_before_and_after_bugdirs(self, bd, previous_revision=None):
+        if previous_revision == None:
+            commit_msg = self.commit_command.stdout
+            assert commit_msg.startswith("Committed "), commit_msg
+            after_revision = commit_msg[len("Committed "):]
+            before_revision = bd.vcs.revision_id(-2)
+        else:
+            before_revision = previous_revision
+        if before_revision == None:
+            # this commit was the initial commit
+            before_bd = libbe.bugdir.BugDir(from_disk=False,
+                                            manipulate_encodings=False)
+        else:
+            before_bd = bd.duplicate_bugdir(before_revision)
+        #after_bd = bd.duplicate_bugdir(after_revision)
+        after_bd = bd # assume no changes since commit a few cycles ago
+        return (before_bd, after_bd)
+    def _subscriber_header(self, bd, previous_revision=None):
+        root_dir = os.path.basename(bd.root)
+        if previous_revision == None:
+            subject = "Changes to %s on %s by %s" \
+                % (root_dir, THIS_SERVER, self.author_addr())
+        else:
+            subject = "Changes to %s on %s since revision %s" \
+                % (root_dir, THIS_SERVER, previous_revision)
+        header = [u"From: %s" % THIS_ADDRESS,
+                  u"To: %s" % u"DUMMY-AUTHOR",
+                  u"Date: %s" % libbe.utility.time_to_str(time.time()),
+                  u"Subject: %s Re: %s" % (SUBJECT_TAG_RESPONSE, subject)
+                  ]
+        return send_pgp_mime.header_from_text(text=u"\n".join(header))
+
+def generate_global_tags(tag_base=u"be-bug"):
+    """
+    Generate a series of tags from a base tag string.
+    """
+    global SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
+        SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL
+    SUBJECT_TAG_BASE = tag_base
+    SUBJECT_TAG_START = u"[%s" % tag_base
+    SUBJECT_TAG_RESPONSE = u"[%s]" % tag_base
+    SUBJECT_TAG_NEW = u"[%s:submit]" % tag_base
+    SUBJECT_TAG_COMMENT = re.compile(u"\[%s:([\-0-9a-z]*)]" % tag_base)
+    SUBJECT_TAG_CONTROL = SUBJECT_TAG_RESPONSE
+
+def open_logfile(logpath=None):
+    """
+    If logpath=None, default to global LOGPATH.
+    Special logpath strings:
+     "-"     set LOGFILE to sys.stderr
+     "none"  disable logging
+    Relative logpaths are expanded relative to _THIS_DIR
+    """
+    global LOGPATH, LOGFILE
+    if logpath != None:
+        if logpath == u"-":
+            LOGPATH = u"stderr"
+            LOGFILE = sys.stderr
+        elif logpath == u"none":
+            LOGPATH = u"none"
+            LOGFILE = None
+        elif os.path.isabs(logpath):
+            LOGPATH = logpath
+        else:
+            LOGPATH = os.path.join(_THIS_DIR, logpath)
+    if LOGFILE == None and LOGPATH != u"none":
+        LOGFILE = codecs.open(LOGPATH, u"a+", ENCODING)
+        LOGFILE.write(u"Default encoding: %s\n" % ENCODING)
+
+def close_logfile():
+    if LOGFILE != None and LOGPATH not in [u"stderr", u"none"]:
+        LOGFILE.close()
+
+def test():
+    unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+    suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
+    result = unittest.TextTestRunner(verbosity=2).run(suite)
+    num_errors = len(result.errors)
+    num_failures = len(result.failures)
+    num_bad = num_errors + num_failures
+    return num_bad
+
+def main(args):
+    from optparse import OptionParser
+    global AUTOCOMMIT, BE_DIR
+
+    usage="be-handle-mail [options]\n\n%s" % (__doc__)
+    parser = OptionParser(usage=usage)
+    parser.add_option('-b', '--be-dir', dest='be_dir', default=BE_DIR,
+                      metavar="DIR",
+                      help='Select the BE directory to serve (%default).')
+    parser.add_option('-t', '--tag-base', dest='tag_base',
+                      default=SUBJECT_TAG_BASE, metavar="TAG",
+                      help='Set the subject tag base (%default).')
+    parser.add_option('-o', '--output', dest='output', action='store_true',
+                      help="Don't mail the generated message, print it to stdout instead.  Useful for testing be-handle-mail functionality without the whole mail transfer agent and procmail setup.")
+    parser.add_option('-l', '--logfile', dest='logfile', metavar='LOGFILE',
+                      help='Set the logfile to LOGFILE.  Relative paths are relative to the location of this be-handle-mail file (%s).  The special value of "-" directs the log output to stderr, and "none" disables logging.' % _THIS_DIR)
+    parser.add_option('-a', '--disable-autocommit', dest='autocommit',
+                      default=True, action='store_false',
+                      help='Disable the autocommit after parsing the email.')
+    parser.add_option('-s', '--disable-subscribers', dest='subscribers',
+                      default=True, action='store_false',
+                      help='Disable subscriber notification emails.')
+    parser.add_option('--notify-since', dest='notify_since', metavar='REVISION',
+                      help='Notify subscribers of all changes since REVISION.  When this option is set, no input email parsing is done.')
+    parser.add_option('--test', dest='test', action='store_true',
+                      help='Run internal unit-tests and exit.')
+
+    pargs = args
+    options,args = parser.parse_args(args[1:])
+
+    if options.test == True:
+        num_bad = test()
+        if num_bad > 126:
+            num_bad = 1
+        sys.exit(num_bad)
+    
+    BE_DIR = options.be_dir
+    AUTOCOMMIT = options.autocommit
+
+    if options.notify_since == None:
+        msg_text = sys.stdin.read()
+
+    libbe.encoding.set_IO_stream_encodings(ENCODING) # _after_ reading message
+    open_logfile(options.logfile)
+    generate_global_tags(options.tag_base)
+
+    if options.notify_since != None:
+        if options.subscribers == True:
+            if LOGFILE != None:
+                LOGFILE.write(u"Checking for subscribers to notify since revision %s\n"
+                              % options.notify_since)
+            try:
+                m = Message(disable_parsing=True)
+                emails = m.subscriber_emails(options.notify_since)
+            except NotificationFailed, e:
+                if LOGFILE != None:
+                    LOGFILE.write(unicode(e) + u"\n")
+            else:
+                for msg in emails:
+                    if options.output == True:
+                        print send_pgp_mime.flatten(msg, to_unicode=True)
+                    else:
+                        send_pgp_mime.mail(msg, send_pgp_mime.sendmail)
+            close_logfile()
+        sys.exit(0)
+
+    if len(msg_text.strip()) == 0: # blank email!?
+        if LOGFILE != None:
+            LOGFILE.write(u"Blank email!\n")
+            close_logfile()
+        sys.exit(1)
+    try:
+        m = Message(msg_text)
+        m.run()
+    except InvalidEmail, e:
+        response = e.response()
+    except Exception, e:
+        if LOGFILE != None:
+            LOGFILE.write(u"Uncaught exception:\n%s\n" % (e,))
+            traceback.print_tb(sys.exc_traceback, file=LOGFILE)
+            close_logfile()
+        sys.exit(1)
+    else:
+        response = m.response_email()
+    if options.output == True:
+        print send_pgp_mime.flatten(response, to_unicode=True)
+    elif m.confirm == True:
+        if LOGFILE != None:
+            LOGFILE.write(u"Sending response to %s\n" % m.author_addr())
+            LOGFILE.write(u"\n%s\n\n" % send_pgp_mime.flatten(response,
+                                                              to_unicode=True))
+        send_pgp_mime.mail(response, send_pgp_mime.sendmail)
+    else:
+        if LOGFILE != None:
+            LOGFILE.write(u"Response declined by %s\n" % m.author_addr())
+    if options.subscribers == True:
+        if LOGFILE != None:
+            LOGFILE.write(u"Checking for subscribers\n")
+        try:
+            emails = m.subscriber_emails()
+        except NotificationFailed, e:
+            if LOGFILE != None:
+                LOGFILE.write(unicode(e) + u"\n")
+        else:
+            for msg in emails:
+                if options.output == True:
+                    print send_pgp_mime.flatten(msg, to_unicode=True)
+                else:
+                    send_pgp_mime.mail(msg, send_pgp_mime.sendmail)
+
+    close_logfile()
+
+
+class GenerateGlobalTagsTestCase (unittest.TestCase):
+    def setUp(self):
+        super(GenerateGlobalTagsTestCase, self).setUp()
+        self.save_global_tags()
+    def tearDown(self):
+        self.restore_global_tags()
+        super(GenerateGlobalTagsTestCase, self).tearDown()
+    def save_global_tags(self):
+        self.saved_globals = [SUBJECT_TAG_BASE, SUBJECT_TAG_START,
+                              SUBJECT_TAG_RESPONSE, SUBJECT_TAG_NEW,
+                              SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL]
+    def restore_global_tags(self):
+        global SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
+            SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL
+        SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
+            SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL = \
+            self.saved_globals
+    def test_restore_global_tags(self):
+        "Test global tag restoration by teardown function."
+        global SUBJECT_TAG_BASE
+        self.failUnlessEqual(SUBJECT_TAG_BASE, u"be-bug")
+        SUBJECT_TAG_BASE = "projectX-bug"
+        self.failUnlessEqual(SUBJECT_TAG_BASE, u"projectX-bug")
+        self.restore_global_tags()
+        self.failUnlessEqual(SUBJECT_TAG_BASE, u"be-bug")
+    def test_subject_tag_base(self):
+        "Should set SUBJECT_TAG_BASE global correctly"
+        generate_global_tags(u"projectX-bug")
+        self.failUnlessEqual(SUBJECT_TAG_BASE, u"projectX-bug")
+    def test_subject_tag_start(self):
+        "Should set SUBJECT_TAG_START global correctly"
+        generate_global_tags(u"projectX-bug")
+        self.failUnlessEqual(SUBJECT_TAG_START, u"[projectX-bug")
+    def test_subject_tag_response(self):
+        "Should set SUBJECT_TAG_RESPONSE global correctly"
+        generate_global_tags(u"projectX-bug")
+        self.failUnlessEqual(SUBJECT_TAG_RESPONSE, u"[projectX-bug]")
+    def test_subject_tag_new(self):
+        "Should set SUBJECT_TAG_NEW global correctly"
+        generate_global_tags(u"projectX-bug")
+        self.failUnlessEqual(SUBJECT_TAG_NEW, u"[projectX-bug:submit]")
+    def test_subject_tag_control(self):
+        "Should set SUBJECT_TAG_CONTROL global correctly"
+        generate_global_tags(u"projectX-bug")
+        self.failUnlessEqual(SUBJECT_TAG_CONTROL, u"[projectX-bug]")
+    def test_subject_tag_comment(self):
+        "Should set SUBJECT_TAG_COMMENT global correctly"
+        generate_global_tags(u"projectX-bug")
+        m = SUBJECT_TAG_COMMENT.match("[projectX-bug:xyz-123]")
+        self.failUnlessEqual(len(m.groups()), 1)
+        self.failUnlessEqual(m.group(1), u"xyz-123")
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/interfaces/email/interactive/becommands/assign.py b/interfaces/email/interactive/becommands/assign.py
new file mode 100644 (file)
index 0000000..794f028
--- /dev/null
@@ -0,0 +1,87 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Marien Zwart <marienz@gentoo.org>
+#                         Thomas Gerigk <tgerigk@gmx.de>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Assign an individual or group to fix a bug"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> bd.bug_from_shortname("a").assigned is None
+    True
+
+    >>> execute(["a"], manipulate_encodings=False)
+    >>> bd._clear_bugs()
+    >>> bd.bug_from_shortname("a").assigned == bd.user_id
+    True
+
+    >>> execute(["a", "someone"], manipulate_encodings=False)
+    >>> bd._clear_bugs()
+    >>> print bd.bug_from_shortname("a").assigned
+    someone
+
+    >>> execute(["a","none"], manipulate_encodings=False)
+    >>> bd._clear_bugs()
+    >>> bd.bug_from_shortname("a").assigned is None
+    True
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True})
+    assert(len(args) in (0, 1, 2))
+    if len(args) == 0:
+        raise cmdutil.UsageError("Please specify a bug id.")
+    if len(args) > 2:
+        help()
+        raise cmdutil.UsageError("Too many arguments.")
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
+    bug = bd.bug_from_shortname(args[0])
+    if len(args) == 1:
+        bug.assigned = bd.user_id
+    elif len(args) == 2:
+        if args[1] == "none":
+            bug.assigned = None
+        else:
+            bug.assigned = args[1]
+    bd.save()
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be assign BUG-ID [ASSIGNEE]")
+    return parser
+
+longhelp = """
+Assign a person to fix a bug.
+
+By default, the bug is self-assigned.  If an assignee is specified, the bug
+will be assigned to that person.
+
+Assignees should be the person's Bugs Everywhere identity, the string that
+appears in Creator fields.
+
+To un-assign a bug, specify "none" for the assignee.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/close.py b/interfaces/email/interactive/becommands/close.py
new file mode 100644 (file)
index 0000000..0532ed2
--- /dev/null
@@ -0,0 +1,60 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Marien Zwart <marienz@gentoo.org>
+#                         Thomas Gerigk <tgerigk@gmx.de>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Close a bug"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> from libbe import bugdir
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> print bd.bug_from_shortname("a").status
+    open
+    >>> execute(["a"], manipulate_encodings=False)
+    >>> bd._clear_bugs()
+    >>> print bd.bug_from_shortname("a").status
+    closed
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True})
+    if len(args) == 0:
+        raise cmdutil.UsageError("Please specify a bug id.")
+    if len(args) > 1:
+        raise cmdutil.UsageError("Too many arguments.")
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
+    bug.status = "closed"
+    bd.save()
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be close BUG-ID")
+    return parser
+
+longhelp="""
+Close the bug identified by BUG-ID.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/comment.py b/interfaces/email/interactive/becommands/comment.py
new file mode 100644 (file)
index 0000000..9a614b2
--- /dev/null
@@ -0,0 +1,228 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Chris Ball <cjb@laptop.org>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Add a comment to a bug"""
+from libbe import cmdutil, bugdir, comment, editor
+import os
+import sys
+try: # import core module, Python >= 2.5
+    from xml.etree import ElementTree
+except ImportError: # look for non-core module
+    from elementtree import ElementTree
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import time
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> execute(["a", "This is a comment about a"], manipulate_encodings=False)
+    >>> bd._clear_bugs()
+    >>> bug = cmdutil.bug_from_shortname(bd, "a")
+    >>> bug.load_comments(load_full=False)
+    >>> comment = bug.comment_root[0]
+    >>> print comment.body
+    This is a comment about a
+    <BLANKLINE>
+    >>> comment.author == bd.user_id
+    True
+    >>> comment.time <= int(time.time())
+    True
+    >>> comment.in_reply_to is None
+    True
+
+    >>> if 'EDITOR' in os.environ:
+    ...     del os.environ["EDITOR"]
+    >>> execute(["b"], manipulate_encodings=False)
+    Traceback (most recent call last):
+    UserError: No comment supplied, and EDITOR not specified.
+
+    >>> os.environ["EDITOR"] = "echo 'I like cheese' > "
+    >>> execute(["b"], manipulate_encodings=False)
+    >>> bd._clear_bugs()
+    >>> bug = cmdutil.bug_from_shortname(bd, "b")
+    >>> bug.load_comments(load_full=False)
+    >>> comment = bug.comment_root[0]
+    >>> print comment.body
+    I like cheese
+    <BLANKLINE>
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    complete(options, args, parser)
+    if len(args) == 0:
+        raise cmdutil.UsageError("Please specify a bug or comment id.")
+    if len(args) > 2:
+        raise cmdutil.UsageError("Too many arguments.")
+
+    shortname = args[0]
+    if shortname.count(':') > 1:
+        raise cmdutil.UserError("Invalid id '%s'." % shortname)
+    elif shortname.count(':') == 1:
+        # Split shortname generated by Comment.comment_shortnames()
+        bugname = shortname.split(':')[0]
+        is_reply = True
+    else:
+        bugname = shortname
+        is_reply = False
+
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, bugname)
+    bug.load_comments(load_full=False)
+    if is_reply:
+        parent = bug.comment_root.comment_from_shortname(shortname,
+                                                         bug_shortname=bugname)
+    else:
+        parent = bug.comment_root
+
+    if len(args) == 1: # try to launch an editor for comment-body entry
+        try:
+            if parent == bug.comment_root:
+                parent_body = bug.summary+"\n"
+            else:
+                parent_body = parent.body
+            estr = "Please enter your comment above\n\n> %s\n" \
+                % ("\n> ".join(parent_body.splitlines()))
+            body = editor.editor_string(estr)
+        except editor.CantFindEditor, e:
+            raise cmdutil.UserError, "No comment supplied, and EDITOR not specified."
+        if body is None:
+            raise cmdutil.UserError("No comment entered.")
+    elif args[1] == '-': # read body from stdin
+        binary = not (options.content_type == None
+                      or options.content_type.startswith("text/"))
+        if not binary:
+            body = sys.stdin.read()
+            if not body.endswith('\n'):
+                body+='\n'
+        else: # read-in without decoding
+            body = sys.__stdin__.read()
+    else: # body = arg[1]
+        body = args[1]
+        if not body.endswith('\n'):
+            body+='\n'
+
+    if options.XML == False:
+        new = parent.new_reply(body=body)
+        if options.author != None:
+            new.author = options.author
+        if options.alt_id != None:
+            new.alt_id = options.alt_id
+        if options.content_type != None:
+            new.content_type = options.content_type
+    else: # import XML comment [list]
+        # read in the comments
+        str_body = body.encode("unicode_escape").replace(r'\n', '\n')
+        comment_list = ElementTree.XML(str_body)
+        if comment_list.tag not in ["bug", "comment-list"]:
+            raise comment.InvalidXML(
+                comment_list, "root element must be <bug> or <comment-list>")
+        new_comments = []
+        ids = []
+        for c in bug.comment_root.traverse():
+            ids.append(c.uuid)
+            if c.alt_id != None:
+                ids.append(c.alt_id)
+        for child in comment_list.getchildren():
+            if child.tag == "comment":
+                new = comment.Comment(bug)
+                new.from_xml(unicode(ElementTree.tostring(child)).decode("unicode_escape"))
+                if new.alt_id in ids:
+                    raise cmdutil.UserError(
+                        "Clashing comment alt_id: %s" % new.alt_id)
+                ids.append(new.uuid)
+                if new.alt_id != None:
+                    ids.append(new.alt_id)
+                if new.in_reply_to == None:
+                    new.in_reply_to = parent.uuid
+                new_comments.append(new)
+            else:
+                print >> sys.stderr, "Ignoring unknown tag %s in %s" \
+                    % (child.tag, comment_list.tag)
+        try:
+            comment.list_to_root(new_comments,bug,root=parent, # link new comments
+                                 ignore_missing_references=options.ignore_missing_references)
+        except comment.MissingReference, e:
+            raise cmdutil.UserError(e)
+        # Protect against programmer error causing data loss:
+        kids = [c.uuid for c in parent.traverse()]
+        for nc in new_comments:
+            assert nc.uuid in kids, "%s wasn't added to %s" % (nc.uuid, parent.uuid)
+            nc.save()
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be comment ID [COMMENT]")
+    parser.add_option("-a", "--author", metavar="AUTHOR", dest="author",
+                      help="Set the comment author", default=None)
+    parser.add_option("--alt-id", metavar="ID", dest="alt_id",
+                      help="Set an alternate comment ID", default=None)
+    parser.add_option("-c", "--content-type", metavar="MIME", dest="content_type",
+                      help="Set comment content-type (e.g. text/plain)", default=None)
+    parser.add_option("-x", "--xml", action="store_true", default=False,
+                      dest='XML', help="Use COMMENT to specify an XML comment description rather than the comment body.  The root XML element should be either <bug> or <comment-list> with one or more <comment> children.  The syntax for the <comment> elements should match that generated by 'be show --xml COMMENT-ID'.  Unrecognized tags are ignored.  Missing tags are left at the default value.  The comment UUIDs are always auto-generated, so if you set a <uuid> field, but no <alt-id> field, your <uuid> will be used as the comment's <alt-id>.  An exception is raised if <alt-id> conflicts with an existing comment.")
+    parser.add_option("-i", "--ignore-missing-references", action="store_true",
+                      dest="ignore_missing_references",
+                      help="For XML import, if any comment's <in-reply-to> refers to a non-existent comment, ignore it (instead of raising an exception).")
+    return parser
+
+longhelp="""
+To add a comment to a bug, use the bug ID as the argument.  To reply
+to another comment, specify the comment name (as shown in "be show"
+output).  COMMENT, if specified, should be either the text of your
+comment or "-", in which case the text will be read from stdin.  If
+you do not specify a COMMENT, $EDITOR is used to launch an editor.  If
+COMMENT is unspecified and EDITOR is not set, no comment will be
+created.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+    for option,value in cmdutil.option_value_pairs(options, parser):
+        if value == "--complete":
+            # no argument-options at the moment, so this is future-proofing
+            raise cmdutil.GetCompletions()
+    for pos,value in enumerate(args):
+        if value == "--complete":
+            if pos == 0: # fist positional argument is a bug or comment id
+                if len(args) >= 2:
+                    partial = args[1].split(':')[0] # take only bugid portion
+                else:
+                    partial = ""
+                ids = []
+                try:
+                    bd = bugdir.BugDir(from_disk=True,
+                                       manipulate_encodings=False)
+                    bugs = []
+                    for uuid in bd.list_uuids():
+                        if uuid.startswith(partial):
+                            bug = bd.bug_from_uuid(uuid)
+                            if bug.active == True:
+                                bugs.append(bug)
+                    for bug in bugs:
+                        shortname = bd.bug_shortname(bug)
+                        ids.append(shortname)
+                        bug.load_comments(load_full=False)
+                        for id,comment in bug.comment_shortnames(shortname):
+                            ids.append(id)
+                except bugdir.NoBugDir:
+                    pass
+                raise cmdutil.GetCompletions(ids)
+            raise cmdutil.GetCompletions()
diff --git a/interfaces/email/interactive/becommands/commit.py b/interfaces/email/interactive/becommands/commit.py
new file mode 100644 (file)
index 0000000..dc70e7e
--- /dev/null
@@ -0,0 +1,78 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Commit the currently pending changes to the repository"""
+from libbe import cmdutil, bugdir, editor, vcs
+import sys
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os, time
+    >>> from libbe import bug
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> full_path = "testfile"
+    >>> test_contents = "A test file"
+    >>> bd.vcs.set_file_contents(full_path, test_contents)
+    >>> execute(["Added %s." % (full_path)], manipulate_encodings=False) # doctest: +ELLIPSIS
+    Committed ...
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser)
+    if len(args) != 1:
+        raise cmdutil.UsageError("Please supply a commit message")
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if args[0] == '-': # read summary from stdin
+        assert options.body != "EDITOR", \
+          "Cannot spawn and editor when the summary is using stdin."
+        summary = sys.stdin.readline()
+    else:
+        summary = args[0]
+    if options.body == None:
+        body = None
+    elif options.body == "EDITOR":
+        body = editor.editor_string("Please enter your commit message above")
+    else:
+        body = bd.vcs.get_file_contents(options.body, allow_no_vcs=True)
+    try:
+        revision = bd.vcs.commit(summary, body=body,
+                                 allow_empty=options.allow_empty)
+    except vcs.EmptyCommit, e:
+        print e
+        return 1
+    else:
+        print "Committed %s" % revision
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be commit COMMENT")
+    parser.add_option("-b", "--body", metavar="FILE", dest="body",
+                      help='Provide a detailed body for the commit message.  In the special case that FILE == "EDITOR", spawn an editor to enter the body text (in which case you cannot use stdin for the summary)', default=None)
+    parser.add_option("-a", "--allow-empty", dest="allow_empty",
+                      help="Allow empty commits",
+                      default=False, action="store_true")
+    return parser
+
+longhelp="""
+Commit the current repository status.  The summary specified on the
+commandline is a string (only one line) that describes the commit
+briefly or "-", in which case the string will be read from stdin.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/depend.py b/interfaces/email/interactive/becommands/depend.py
new file mode 100644 (file)
index 0000000..f72b8ba
--- /dev/null
@@ -0,0 +1,339 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Add/remove bug dependencies"""
+from libbe import cmdutil, bugdir, tree
+import os, copy
+__desc__ = __doc__
+
+BLOCKS_TAG="BLOCKS:"
+BLOCKED_BY_TAG="BLOCKED-BY:"
+
+class BrokenLink (Exception):
+    def __init__(self, blocked_bug, blocking_bug, blocks=True):
+        if blocks == True:
+            msg = "Missing link: %s blocks %s" \
+                % (blocking_bug.uuid, blocked_bug.uuid)
+        else:
+            msg = "Missing link: %s blocked by %s" \
+                % (blocked_bug.uuid, blocking_bug.uuid)
+        Exception.__init__(self, msg)
+        self.blocked_bug = blocked_bug
+        self.blocking_bug = blocking_bug
+
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> from libbe import utility
+    >>> bd = bugdir.SimpleBugDir()
+    >>> bd.save()
+    >>> os.chdir(bd.root)
+    >>> execute(["a", "b"], manipulate_encodings=False)
+    a blocked by:
+    b
+    >>> execute(["a"], manipulate_encodings=False)
+    a blocked by:
+    b
+    >>> execute(["--show-status", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    a blocked by:
+    b closed
+    >>> execute(["b", "a"], manipulate_encodings=False)
+    b blocked by:
+    a
+    b blocks:
+    a
+    >>> execute(["--show-status", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    a blocked by:
+    b closed
+    a blocks:
+    b closed
+    >>> execute(["-r", "b", "a"], manipulate_encodings=False)
+    b blocks:
+    a
+    >>> execute(["-r", "a", "b"], manipulate_encodings=False)
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True,
+                                         1: lambda bug : bug.active==True})
+
+    if options.repair == True:
+        if len(args) > 0:
+            raise cmdutil.UsageError("No arguments with --repair calls.")
+    elif len(args) < 1:
+        raise cmdutil.UsageError("Please a bug id.")
+    elif len(args) > 2:
+        help()
+        raise cmdutil.UsageError("Too many arguments.")
+    elif len(args) == 2 and options.tree_depth != None:
+        raise cmdutil.UsageError("Only one bug id used in tree mode.")
+        
+
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if options.repair == True:
+        good,fixed,broken = check_dependencies(bd, repair_broken_links=True)
+        assert len(broken) == 0, broken
+        if len(fixed) > 0:
+            print "Fixed the following links:"
+            print "\n".join(["%s |-- %s" % (blockee.uuid, blocker.uuid)
+                             for blockee,blocker in fixed])
+        return 0
+
+    bugA = cmdutil.bug_from_shortname(bd, args[0])
+
+    if options.tree_depth != None:
+        dtree = DependencyTree(bd, bugA, options.tree_depth)
+        if len(dtree.blocked_by_tree()) > 0:
+            print "%s blocked by:" % bugA.uuid
+            for depth,node in dtree.blocked_by_tree().thread():
+                if depth == 0: continue
+                print "%s%s" % (" "*(depth), node.bug.string(shortlist=True))
+        if len(dtree.blocks_tree()) > 0:
+            print "%s blocks:" % bugA.uuid
+            for depth,node in dtree.blocks_tree().thread():
+                if depth == 0: continue
+                print "%s%s" % (" "*(depth), node.bug.string(shortlist=True))
+        return 0
+
+    if len(args) == 2:
+        bugB = cmdutil.bug_from_shortname(bd, args[1])
+        if options.remove == True:
+            remove_block(bugA, bugB)
+        else: # add the dependency
+            add_block(bugA, bugB)
+
+    blocked_by = get_blocked_by(bd, bugA)
+    if len(blocked_by) > 0:
+        print "%s blocked by:" % bugA.uuid
+        if options.show_status == True:
+            print '\n'.join(["%s\t%s" % (bug.uuid, bug.status)
+                             for bug in blocked_by])
+        else:
+            print '\n'.join([bug.uuid for bug in blocked_by])
+    blocks = get_blocks(bd, bugA)
+    if len(blocks) > 0:
+        print "%s blocks:" % bugA.uuid
+        if options.show_status == True:
+            print '\n'.join(["%s\t%s" % (bug.uuid, bug.status)
+                             for bug in blocks])
+        else:
+            print '\n'.join([bug.uuid for bug in blocks])
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be depend BUG-ID [BUG-ID]\nor:    be depend --repair")
+    parser.add_option("-r", "--remove", action="store_true",
+                      dest="remove", default=False,
+                      help="Remove dependency (instead of adding it)")
+    parser.add_option("-s", "--show-status", action="store_true",
+                      dest="show_status", default=False,
+                      help="Show status of blocking bugs")
+    parser.add_option("-t", "--tree-depth", metavar="DEPTH", default=None,
+                      type="int", dest="tree_depth",
+                      help="Print dependency tree rooted at BUG-ID with DEPTH levels of both blockers and blockees.  Set DEPTH <= 0 to disable the depth limit.")
+    parser.add_option("--repair", action="store_true",
+                      dest="repair", default=False,
+                      help="Check for and repair one-way links")
+    return parser
+
+longhelp="""
+Set a dependency with the second bug (B) blocking the first bug (A).
+If bug B is not specified, just print a list of bugs blocking (A).
+
+To search for bugs blocked by a particular bug, try
+  $ be list --extra-strings BLOCKED-BY:<your-bug-uuid>
+
+In repair mode, add the missing direction to any one-way links.
+
+The "|--" symbol in the repair-mode output is inspired by the
+"negative feedback" arrow common in biochemistry.  See, for example
+  http://www.nature.com/nature/journal/v456/n7223/images/nature07513-f5.0.jpg
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
+
+# internal helper functions
+
+def _generate_blocks_string(blocked_bug):
+    return "%s%s" % (BLOCKS_TAG, blocked_bug.uuid)
+
+def _generate_blocked_by_string(blocking_bug):
+    return "%s%s" % (BLOCKED_BY_TAG, blocking_bug.uuid)
+
+def _parse_blocks_string(string):
+    assert string.startswith(BLOCKS_TAG)
+    return string[len(BLOCKS_TAG):]
+
+def _parse_blocked_by_string(string):
+    assert string.startswith(BLOCKED_BY_TAG)
+    return string[len(BLOCKED_BY_TAG):]
+
+def _add_remove_extra_string(bug, string, add):
+    estrs = bug.extra_strings
+    if add == True:
+        estrs.append(string)
+    else: # remove the string
+        estrs.remove(string)
+    bug.extra_strings = estrs # reassign to notice change
+
+def _get_blocks(bug):
+    uuids = []
+    for line in bug.extra_strings:
+        if line.startswith(BLOCKS_TAG):
+            uuids.append(_parse_blocks_string(line))
+    return uuids
+
+def _get_blocked_by(bug):
+    uuids = []
+    for line in bug.extra_strings:
+        if line.startswith(BLOCKED_BY_TAG):
+            uuids.append(_parse_blocked_by_string(line))
+    return uuids
+
+def _repair_one_way_link(blocked_bug, blocking_bug, blocks=None):
+    if blocks == True: # add blocks link
+        blocks_string = _generate_blocks_string(blocked_bug)
+        _add_remove_extra_string(blocking_bug, blocks_string, add=True)
+    else: # add blocked by link
+        blocked_by_string = _generate_blocked_by_string(blocking_bug)
+        _add_remove_extra_string(blocked_bug, blocked_by_string, add=True)
+
+# functions exposed to other modules
+
+def add_block(blocked_bug, blocking_bug):
+    blocked_by_string = _generate_blocked_by_string(blocking_bug)
+    _add_remove_extra_string(blocked_bug, blocked_by_string, add=True)
+    blocks_string = _generate_blocks_string(blocked_bug)
+    _add_remove_extra_string(blocking_bug, blocks_string, add=True)
+
+def remove_block(blocked_bug, blocking_bug):
+    blocked_by_string = _generate_blocked_by_string(blocking_bug)
+    _add_remove_extra_string(blocked_bug, blocked_by_string, add=False)
+    blocks_string = _generate_blocks_string(blocked_bug)
+    _add_remove_extra_string(blocking_bug, blocks_string, add=False)
+
+def get_blocks(bugdir, bug):
+    """
+    Return a list of bugs that the given bug blocks.
+    """
+    blocks = []
+    for uuid in _get_blocks(bug):
+        blocks.append(bugdir.bug_from_uuid(uuid))
+    return blocks
+
+def get_blocked_by(bugdir, bug):
+    """
+    Return a list of bugs blocking the given bug blocks.
+    """
+    blocked_by = []
+    for uuid in _get_blocked_by(bug):
+        blocked_by.append(bugdir.bug_from_uuid(uuid))
+    return blocked_by
+
+def check_dependencies(bugdir, repair_broken_links=False):
+    """
+    Check that links are bi-directional for all bugs in bugdir.
+
+    >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+    >>> a = bd.bug_from_uuid("a")
+    >>> b = bd.bug_from_uuid("b")
+    >>> blocked_by_string = _generate_blocked_by_string(b)
+    >>> _add_remove_extra_string(a, blocked_by_string, add=True)
+    >>> good,repaired,broken = check_dependencies(bd, repair_broken_links=False)
+    >>> good
+    []
+    >>> repaired
+    []
+    >>> broken
+    [(Bug(uuid='a'), Bug(uuid='b'))]
+    >>> _get_blocks(b)
+    []
+    >>> good,repaired,broken = check_dependencies(bd, repair_broken_links=True)
+    >>> _get_blocks(b)
+    ['a']
+    >>> good
+    []
+    >>> repaired
+    [(Bug(uuid='a'), Bug(uuid='b'))]
+    >>> broken
+    []
+    """
+    if bugdir.sync_with_disk == True:
+        bugdir.load_all_bugs()
+    good_links = []
+    fixed_links = []
+    broken_links = []
+    for bug in bugdir:
+        for blocker in get_blocked_by(bugdir, bug):
+            blocks = get_blocks(bugdir, blocker)
+            if (bug, blocks) in good_links+fixed_links+broken_links:
+                continue # already checked that link
+            if bug not in blocks:
+                if repair_broken_links == True:
+                    _repair_one_way_link(bug, blocker, blocks=True)
+                    fixed_links.append((bug, blocker))
+                else:
+                    broken_links.append((bug, blocker))
+            else:
+                good_links.append((bug, blocker))
+        for blockee in get_blocks(bugdir, bug):
+            blocked_by = get_blocked_by(bugdir, blockee)
+            if (blockee, bug) in good_links+fixed_links+broken_links:
+                continue # already checked that link
+            if bug not in blocked_by:
+                if repair_broken_links == True:
+                    _repair_one_way_link(blockee, bug, blocks=False)
+                    fixed_links.append((blockee, bug))
+                else:
+                    broken_links.append((blockee, bug))
+            else:
+                good_links.append((blockee, bug))
+    return (good_links, fixed_links, broken_links)
+
+class DependencyTree (object):
+    """
+    Note: should probably be DependencyDiGraph.
+    """
+    def __init__(self, bugdir, root_bug, depth_limit=0):
+        self.bugdir = bugdir
+        self.root_bug = root_bug
+        self.depth_limit = depth_limit
+    def _build_tree(self, child_fn):
+        root = tree.Tree()
+        root.bug = self.root_bug
+        root.depth = 0
+        stack = [root]
+        while len(stack) > 0:
+            node = stack.pop()
+            if self.depth_limit > 0 and node.depth == self.depth_limit:
+                continue
+            for bug in child_fn(self.bugdir, node.bug):
+                child = tree.Tree()
+                child.bug = bug
+                child.depth = node.depth+1
+                node.append(child)
+                stack.append(child)
+        return root
+    def blocks_tree(self):
+        if not hasattr(self, "_blocks_tree"):
+            self._blocks_tree = self._build_tree(get_blocks)
+        return self._blocks_tree
+    def blocked_by_tree(self):
+        if not hasattr(self, "_blocked_by_tree"):
+            self._blocked_by_tree = self._build_tree(get_blocked_by)
+        return self._blocked_by_tree
diff --git a/interfaces/email/interactive/becommands/diff.py b/interfaces/email/interactive/becommands/diff.py
new file mode 100644 (file)
index 0000000..b6ac5b0
--- /dev/null
@@ -0,0 +1,120 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""Compare bug reports with older tree"""
+from libbe import cmdutil, bugdir, diff
+import os
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> bd.set_sync_with_disk(True)
+    >>> original = bd.vcs.commit("Original status")
+    >>> bug = bd.bug_from_uuid("a")
+    >>> bug.status = "closed"
+    >>> changed = bd.vcs.commit("Closed bug a")
+    >>> os.chdir(bd.root)
+    >>> if bd.vcs.versioned == True:
+    ...     execute([original], manipulate_encodings=False)
+    ... else:
+    ...     print "Modified bugs:\\n  a:cm: Bug A\\n    Changed bug settings:\\n      status: open -> closed"
+    Modified bugs:
+      a:cm: Bug A
+        Changed bug settings:
+          status: open -> closed
+    >>> if bd.vcs.versioned == True:
+    ...     execute(["--modified", original], manipulate_encodings=False)
+    ... else:
+    ...     print "a"
+    a
+    >>> if bd.vcs.versioned == False:
+    ...     execute([original], manipulate_encodings=False)
+    ... else:
+    ...     print "This directory is not revision-controlled."
+    This directory is not revision-controlled.
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser)
+    if len(args) == 0:
+        revision = None
+    if len(args) == 1:
+        revision = args[0]
+    if len(args) > 1:
+        raise cmdutil.UsageError("Too many arguments.")
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if bd.vcs.versioned == False:
+        print "This directory is not revision-controlled."
+    else:
+        if revision == None: # get the most recent revision
+            revision = bd.vcs.revision_id(-1)
+        old_bd = bd.duplicate_bugdir(revision)
+        d = diff.Diff(old_bd, bd)
+        tree = d.report_tree()
+
+        uuids = []
+        if options.all == True:
+            options.new = options.modified = options.removed = True
+        if options.new == True:
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/new")])
+        if options.modified == True:
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/mod")])
+        if options.removed == True:
+            uuids.extend([c.name for c in tree.child_by_path("/bugs/rem")])
+        if (options.new or options.modified or options.removed) == True:
+            print "\n".join(uuids)
+        else :
+            rep = tree.report_string()
+            if rep != None:
+                print rep
+        bd.remove_duplicate_bugdir()
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be diff [options] REVISION")
+    # boolean options
+    bools = (("n", "new", "Print UUIDS for new bugs"),
+             ("m", "modified", "Print UUIDS for modified bugs"),
+             ("r", "removed", "Print UUIDS for removed bugs"),
+             ("a", "all", "Print UUIDS for all changed bugs"))
+    for s in bools:
+        attr = s[1].replace('-','_')
+        short = "-%c" % s[0]
+        long = "--%s" % s[1]
+        help = s[2]
+        parser.add_option(short, long, action="store_true",
+                          default=False, dest=attr, help=help)
+    return parser
+
+longhelp="""
+Uses the VCS to compare the current tree with a previous tree, and
+prints a pretty report.  If REVISION is given, it is a specifier for
+the particular previous tree to use.  Specifiers are specific to their
+VCS.
+
+For Arch your specifier must be a fully-qualified revision name.
+
+Besides the standard summary output, you can use the options to output
+UUIDS for the different categories.  This output can be used as the
+input to 'be show' to get and understanding of the current status.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/help.py b/interfaces/email/interactive/becommands/help.py
new file mode 100644 (file)
index 0000000..a8f346a
--- /dev/null
@@ -0,0 +1,68 @@
+# Copyright (C) 2006-2009 Aaron Bentley and Panometrics, Inc.
+#                         Thomas Gerigk <tgerigk@gmx.de>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Print help for given subcommand"""
+from libbe import cmdutil, utility
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=False):
+    """
+    Print help of specified command (the manipulate_encodings argument
+    is ignored).
+
+    >>> execute(["help"])
+    Usage: be help [COMMAND]
+    <BLANKLINE>
+    Options:
+      -h, --help  Print a help message
+      --complete  Print a list of available completions
+    <BLANKLINE>
+    Print help for specified command or list of all commands.
+    <BLANKLINE>
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    complete(options, args, parser)
+    if len(args) > 1:
+        raise cmdutil.UsageError("Too many arguments.")
+    if len(args) == 0:
+        print cmdutil.help()
+    else:
+        try:
+            print cmdutil.help(args[0])
+        except AttributeError:
+            print "No help available"    
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be help [COMMAND]")
+    return parser
+
+longhelp="""
+Print help for specified command or list of all commands.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+    for option, value in cmdutil.option_value_pairs(options, parser):
+        if value == "--complete":
+            # no argument-options at the moment, so this is future-proofing
+            raise cmdutil.GetCompletions()
+    if "--complete" in args:
+        cmds = [command for command,module in cmdutil.iter_commands()]
+        raise cmdutil.GetCompletions(cmds)
diff --git a/interfaces/email/interactive/becommands/html.py b/interfaces/email/interactive/becommands/html.py
new file mode 100644 (file)
index 0000000..908c714
--- /dev/null
@@ -0,0 +1,588 @@
+# Copyright (C) 2009 Gianluca Montecchi <gian@grys.it>
+#                    W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Generate a static HTML dump of the current repository status"""
+from libbe import cmdutil, bugdir, bug
+#from html_data import *
+import codecs, os, re, string, time
+import xml.sax.saxutils, htmlentitydefs
+
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> execute([], manipulate_encodings=False)
+    Creating the html output in html_export
+    >>> os.path.exists("./html_export")
+    True
+    >>> os.path.exists("./html_export/index.html")
+    True
+    >>> os.path.exists("./html_export/index_inactive.html")
+    True
+    >>> os.path.exists("./html_export/bugs")
+    True
+    >>> os.path.exists("./html_export/bugs/a.html")
+    True
+    >>> os.path.exists("./html_export/bugs/b.html")
+    True
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    complete(options, args, parser)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==False})
+    
+    if len(args) == 0:
+        out_dir = options.outdir
+        print "Creating the html output in %s"%out_dir
+    else:
+        out_dir = args[0]
+    if len(args) > 0:
+        raise cmdutil.UsageError, "Too many arguments."
+    
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bd.load_all_bugs()
+    status_list = bug.status_values
+    severity_list = bug.severity_values
+    st = {}
+    se = {}
+    stime = {}
+    bugs_active = []
+    bugs_inactive = []
+    for s in status_list:
+        st[s] = 0
+    for b in sorted(bd, reverse=True):
+        stime[b.uuid]  = b.time
+        if b.active == True:
+            bugs_active.append(b)
+        else:
+            bugs_inactive.append(b)
+        st[b.status] += 1
+    ordered_bug_list = sorted([(value,key) for (key,value) in stime.items()])
+    ordered_bug_list_in = sorted([(value,key) for (key,value) in stime.items()])
+    #open_bug_list = sorted([(value,key) for (key,value) in bugs.items()])
+    
+    html_gen = BEHTMLGen(bd)
+    html_gen.create_index_file(out_dir,  st, bugs_active, ordered_bug_list, "active", bd.encoding)
+    html_gen.create_index_file(out_dir,  st, bugs_inactive, ordered_bug_list, "inactive", bd.encoding)
+    
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be open OUTPUT_DIR")
+    parser.add_option("-o", "--output", metavar="export_dir", dest="outdir",
+        help="Set the output path, default is ./html_export", default="html_export")    
+    return parser
+
+longhelp="""
+Generate a set of html pages representing the current state of the bug
+directory.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+    for option, value in cmdutil.option_value_pairs(options, parser):
+        if "--complete" in args:
+            raise cmdutil.GetCompletions() # no positional arguments for list
+        
+
+def escape(string):
+    if string == None:
+        return ""
+    chars = []
+    for char in xml.sax.saxutils.escape(string):
+        codepoint = ord(char)
+        if codepoint in htmlentitydefs.codepoint2name:
+            char = "&%s;" % htmlentitydefs.codepoint2name[codepoint]
+        chars.append(char)
+    return "".join(chars)
+
+class BEHTMLGen():
+    def __init__(self, bd):
+        self.index_value = ""    
+        self.bd = bd
+        
+        self.css_file = """
+        body {
+        font-family: "lucida grande", "sans serif";
+        color: #333;
+        width: auto;
+        margin: auto;
+        }
+        
+        
+        div.main {
+        padding: 20px;
+        margin: auto;
+        padding-top: 0;
+        margin-top: 1em;
+        background-color: #fcfcfc;
+        }
+        
+        .comment {
+        padding: 20px;
+        margin: auto;
+        padding-top: 20px;
+        margin-top: 0;
+        }
+        
+        .commentF {
+        padding: 0px;
+        margin: auto;
+        padding-top: 0px;
+        paddin-bottom: 20px;
+        margin-top: 0;
+        }
+        
+        tb {
+        border = 1;
+        }
+        
+        .wishlist-row {
+        background-color: #B4FF9B;
+        width: auto;
+        }
+        
+        .minor-row {
+        background-color: #FCFF98;
+        width: auto;
+        }
+        
+        
+        .serious-row {
+        background-color: #FFB648;
+        width: auto;
+        }
+        
+        .critical-row {
+        background-color: #FF752A;
+        width: auto;
+        }
+        
+        .fatal-row {
+        background-color: #FF3300;
+        width: auto;
+        }
+                
+        .person {
+        font-family: courier;
+        }
+        
+        a, a:visited {
+        background: inherit;
+        text-decoration: none;
+        }
+        
+        a {
+        color: #003d41;
+        }
+        
+        a:visited {
+        color: #553d41;
+        }
+        
+        ul {
+        list-style-type: none;
+        padding: 0;
+        }
+        
+        p {
+        width: auto;
+        }
+        
+        .inline-status-image {
+        position: relative;
+        top: 0.2em;
+        }
+        
+        .dimmed {
+        color: #bbb;
+        }
+        
+        table {
+        border-style: 10px solid #313131;
+        border-spacing: 0;
+        width: auto;
+        }
+        
+        table.log {
+        }
+        
+        td {
+        border-width: 0;
+        border-style: none;
+        padding-right: 0.5em;
+        padding-left: 0.5em;
+        width: auto;
+        }
+        
+        .td_sel {
+        background-color: #afafaf;
+        border: 1px solid #afafaf;
+        font-weight:bold;
+        padding-right: 1em;
+        padding-left: 1em;
+        
+        }
+        
+        .td_nsel {
+        border: 0px;
+        padding-right: 1em;
+        padding-left: 1em;
+        }
+        
+        tr {
+        vertical-align: top;
+        width: auto;
+        }
+        
+        h1 {
+        padding: 0.5em;
+        background-color: #305275;
+        margin-top: 0;
+        margin-bottom: 0;
+        color: #fff;
+        margin-left: -20px;
+        margin-right: -20px;  
+        }
+        
+        wid {
+        text-transform: uppercase;
+        font-size: smaller;
+        margin-top: 1em;
+        margin-left: -0.5em;  
+        /*background: #fffbce;*/
+        /*background: #628a0d;*/
+        padding: 5px;
+        color: #305275;
+        }
+        
+        .attrname {
+        text-align: right;
+        font-size: smaller;
+        }
+        
+        .attrval {
+        color: #222;
+        }
+        
+        .issue-closed-fixed {
+        background-image: "green-check.png";
+        }
+        
+        .issue-closed-wontfix {
+        background-image: "red-check.png";
+        }
+        
+        .issue-closed-reorg {
+        background-image: "blue-check.png";
+        }
+        
+        .inline-issue-link {
+        text-decoration: underline;
+        }
+        
+        img {
+        border: 0;
+        }
+        
+        
+        div.footer {
+        font-size: small;
+        padding-left: 20px;
+        padding-right: 20px;
+        padding-top: 5px;
+        padding-bottom: 5px;
+        margin: auto;
+        background: #305275;
+        color: #fffee7;
+        }
+        
+        .footer a {
+        color: #508d91;
+        }
+        
+        
+        .header {
+        font-family: "lucida grande", "sans serif";
+        font-size: smaller;
+        background-color: #a9a9a9;
+        text-align: left;
+        
+        padding-right: 0.5em;
+        padding-left: 0.5em;
+        
+        }
+        
+        
+        .selected-cell {
+        background-color: #e9e9e2;
+        }
+        
+        .plain-cell {
+        background-color: #f9f9f9;
+        }
+        
+        
+        .logcomment {
+        padding-left: 4em;
+        font-size: smaller;
+        }
+        
+        .id {
+        font-family: courier;
+        }
+        
+        .table_bug {
+        background-color: #afafaf;
+        border: 2px solid #afafaf;
+        }
+        
+        .message {
+        }
+        
+        .progress-meter-done {
+        background-color: #03af00;
+        }
+        
+        .progress-meter-undone {
+        background-color: #ddd;
+        }
+        
+        .progress-meter {
+        }
+        
+        """
+        
+        self.index_first = """
+        <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+        <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+        <head>
+        <title>BugsEverywhere Issue Tracker</title>
+        <meta http-equiv="Content-Type" content="text/html; charset=%s" />
+        <link rel="stylesheet" href="style.css" type="text/css" />
+        </head>
+        <body>
+        
+        
+        <div class="main">
+        <h1>BugsEverywhere Bug List</h1>
+        <p></p>
+        <table>
+        
+        <tr>
+        <td class="%%s"><a href="index.html">Active Bugs</a></td>
+        <td class="%%s"><a href="index_inactive.html">Inactive Bugs</a></td>
+        </tr>
+        
+        </table>
+        <table class="table_bug">
+        <tbody>
+        """ % self.bd.encoding
+        
+        self.bug_line ="""
+        <tr class="%s-row">
+        <td ><a href="bugs/%s.html">%s</a></td>
+        <td ><a href="bugs/%s.html">%s</a></td>
+        <td><a href="bugs/%s.html">%s</a></td>
+        <td><a href="bugs/%s.html">%s</a></td>
+        <td><a href="bugs/%s.html">%s</a></td>
+        </tr>
+        """
+        
+        self.detail_first = """
+        <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+        <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+        <head>
+        <title>BugsEverywhere Issue Tracker</title>
+        <meta http-equiv="Content-Type" content="text/html; charset=%s" />
+        <link rel="stylesheet" href="../style.css" type="text/css" />
+        </head>
+        <body>
+        
+        
+        <div class="main">
+        <h1>BugsEverywhere Bug List</h1>
+        <h5><a href="%%s">Back to Index</a></h5>
+        <h2>Bug: _bug_id_</h2>
+        <table >
+        <tbody>
+        """ % self.bd.encoding
+        
+        
+        
+        self.detail_line ="""
+        <tr>
+        <td align="right">%s</td><td>%s</td>
+        </tr>
+        """
+        
+        self.index_last = """
+        </tbody>
+        </table>
+        
+        </div>
+        
+        <div class="footer">Generated by <a href="http://www.bugseverywhere.org/">BugsEverywhere</a> on %s</div>
+        
+        </body>
+        </html>
+        """
+        
+        self.comment_section = """
+        """
+        
+        self.begin_comment_section ="""
+        <tr>
+        <td align="right">Comments:
+        </td>
+        <td>
+        """
+        
+        
+        self.end_comment_section ="""
+        </td>
+        </tr>
+        """
+        
+        self.detail_last = """
+        </tbody>
+        </table>
+        </div>
+        <h5><a href="%s">Back to Index</a></h5>
+        <div class="footer">Generated by <a href="http://www.bugseverywhere.org/">BugsEverywhere</a>.</div>
+        </body>
+        </html>
+        """   
+        
+        
+    def create_index_file(self, out_dir_path,  summary,  bugs, ordered_bug, fileid, encoding):
+        try:
+            os.stat(out_dir_path)
+        except:
+            try:
+                os.mkdir(out_dir_path)
+            except:
+                raise  cmdutil.UsageError, "Cannot create output directory."
+        try:
+            FO = codecs.open(out_dir_path+"/style.css", "w", encoding)
+            FO.write(self.css_file)
+            FO.close()
+        except:
+            raise  cmdutil.UsageError, "Cannot create the style.css file."
+        
+        try:
+            os.mkdir(out_dir_path+"/bugs")
+        except:
+            pass
+        
+        try:
+            if fileid == "active":
+                FO = codecs.open(out_dir_path+"/index.html", "w", encoding)
+                FO.write(self.index_first%('td_sel','td_nsel'))
+            if fileid == "inactive":
+                FO = codecs.open(out_dir_path+"/index_inactive.html", "w", encoding)
+                FO.write(self.index_first%('td_nsel','td_sel'))
+        except:
+            raise  cmdutil.UsageError, "Cannot create the index.html file."
+        
+        c = 0
+        t = len(bugs) - 1
+        for l in range(t,  -1,  -1):
+            line = self.bug_line%(escape(bugs[l].severity),
+                                  escape(bugs[l].uuid), escape(bugs[l].uuid[0:3]),
+                                  escape(bugs[l].uuid), escape(bugs[l].status),
+                                  escape(bugs[l].uuid), escape(bugs[l].severity),
+                                  escape(bugs[l].uuid), escape(bugs[l].summary),
+                                  escape(bugs[l].uuid), escape(bugs[l].time_string)
+                                  )
+            FO.write(line)
+            c += 1
+            self.create_detail_file(bugs[l], out_dir_path, fileid, encoding)
+        when = time.ctime()
+        FO.write(self.index_last%when)
+
+
+    def create_detail_file(self, bug, out_dir_path, fileid, encoding):
+        f = "%s.html"%bug.uuid
+        p = out_dir_path+"/bugs/"+f
+        try:
+            FD = codecs.open(p, "w", encoding)
+        except:
+            raise  cmdutil.UsageError, "Cannot create the detail html file."
+
+        detail_first_ = re.sub('_bug_id_', bug.uuid[0:3], self.detail_first)
+        if fileid == "active":
+            FD.write(detail_first_%"../index.html")
+        if fileid == "inactive":
+            FD.write(detail_first_%"../index_inactive.html")
+            
+        
+         
+        bug_ = self.bd.bug_from_shortname(bug.uuid)
+        bug_.load_comments(load_full=True)
+        
+        FD.write(self.detail_line%("ID : ", bug.uuid))
+        FD.write(self.detail_line%("Short name : ", escape(bug.uuid[0:3])))
+        FD.write(self.detail_line%("Severity : ", escape(bug.severity)))
+        FD.write(self.detail_line%("Status : ", escape(bug.status)))
+        FD.write(self.detail_line%("Assigned : ", escape(bug.assigned)))
+        FD.write(self.detail_line%("Target : ", escape(bug.target)))
+        FD.write(self.detail_line%("Reporter : ", escape(bug.reporter)))
+        FD.write(self.detail_line%("Creator : ", escape(bug.creator)))
+        FD.write(self.detail_line%("Created : ", escape(bug.time_string)))
+        FD.write(self.detail_line%("Summary : ", escape(bug.summary)))
+        FD.write("<tr><td colspan=\"2\"><hr /></td></tr>")
+        FD.write(self.begin_comment_section)
+        tr = []
+        b = ''
+        level = 0
+        stack = []
+        for depth,comment in bug_.comment_root.thread(flatten=False):
+            while len(stack) > depth:
+                stack.pop(-1)      # pop non-parents off the stack
+                FD.write("</div>\n") # close non-parent <div class="comment...
+            assert len(stack) == depth
+            stack.append(comment)
+            lines = ["--------- Comment ---------",
+                     "Name: %s" % comment.uuid,
+                     "From: %s" % escape(comment.author),
+                     "Date: %s" % escape(comment.date),
+                     ""]
+            lines.extend(escape(comment.body).splitlines())
+            if depth == 0:
+                FD.write('<div class="commentF">')
+            else:
+                FD.write('<div class="comment">')
+            FD.write("<br />\n".join(lines)+"<br />\n")
+        while len(stack) > 0:
+            stack.pop(-1)
+            FD.write("</div>\n") # close every remaining <div class="comment...
+        FD.write(self.end_comment_section)
+        if fileid == "active":
+            FD.write(self.detail_last%"../index.html")
+        if fileid == "inactive":
+            FD.write(self.detail_last%"../index_inactive.html")
+        FD.close()
+        
+   
diff --git a/interfaces/email/interactive/becommands/init.py b/interfaces/email/interactive/becommands/init.py
new file mode 100644 (file)
index 0000000..1125d93
--- /dev/null
@@ -0,0 +1,100 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Assign the root directory for bug tracking"""
+import os.path
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> from libbe import utility, vcs
+    >>> import os
+    >>> dir = utility.Dir()
+    >>> try:
+    ...     bugdir.BugDir(dir.path)
+    ... except bugdir.NoBugDir, e:
+    ...     True
+    True
+    >>> execute(['--root', dir.path], manipulate_encodings=False)
+    No revision control detected.
+    Directory initialized.
+    >>> del(dir)
+
+    >>> dir = utility.Dir()
+    >>> os.chdir(dir.path)
+    >>> vcs = vcs.installed_vcs()
+    >>> vcs.init('.')
+    >>> print vcs.name
+    Arch
+    >>> execute([], manipulate_encodings=False)
+    Using Arch for revision control.
+    Directory initialized.
+    >>> vcs.cleanup()
+
+    >>> try:
+    ...     execute(['--root', '.'], manipulate_encodings=False)
+    ... except cmdutil.UserError, e:
+    ...     str(e).startswith("Directory already initialized: ")
+    True
+    >>> execute(['--root', '/highly-unlikely-to-exist'], manipulate_encodings=False)
+    Traceback (most recent call last):
+    UserError: No such directory: /highly-unlikely-to-exist
+    >>> os.chdir('/')
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser)
+    if len(args) > 0:
+        raise cmdutil.UsageError
+    try:
+        bd = bugdir.BugDir(options.root_dir, from_disk=False,
+                           sink_to_existing_root=False,
+                           assert_new_BugDir=True,
+                           manipulate_encodings=manipulate_encodings)
+    except bugdir.NoRootEntry:
+        raise cmdutil.UserError("No such directory: %s" % options.root_dir)
+    except bugdir.AlreadyInitialized:
+        raise cmdutil.UserError("Directory already initialized: %s" % options.root_dir)
+    bd.save()
+    if bd.vcs.name is not "None":
+        print "Using %s for revision control." % bd.vcs.name
+    else:
+        print "No revision control detected."
+    print "Directory initialized."
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be init")
+    parser.add_option("-r", "--root", metavar="DIR", dest="root_dir",
+                      help="Set root dir to something other than the current directory.",
+                      default=".")
+    return parser
+
+longhelp="""
+This command initializes Bugs Everywhere support for the specified directory
+and all its subdirectories.  It will auto-detect any supported revision control
+system.  You can use "be set vcs_name" to change the vcs being used.
+
+The directory defaults to your current working directory.
+
+It is usually a good idea to put the Bugs Everywhere root at the source code
+root, but you can put it anywhere.  If you root Bugs Everywhere in a
+subdirectory, then only bugs created in that subdirectory (and its children)
+will appear there.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/list.py b/interfaces/email/interactive/becommands/list.py
new file mode 100644 (file)
index 0000000..12e1e29
--- /dev/null
@@ -0,0 +1,248 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Chris Ball <cjb@laptop.org>
+#                         Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""List bugs"""
+from libbe import cmdutil, bugdir, bug
+import os
+import re
+__desc__ = __doc__
+
+# get a list of * for cmp_*() comparing two bugs. 
+AVAILABLE_CMPS = [fn[4:] for fn in dir(bug) if fn[:4] == 'cmp_']
+AVAILABLE_CMPS.remove("attr") # a cmp_* template.
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> execute([], manipulate_encodings=False)
+    a:om: Bug A
+    >>> execute(["--status", "all"], manipulate_encodings=False)
+    a:om: Bug A
+    b:cm: Bug B
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    complete(options, args, parser)    
+    if len(args) > 0:
+        raise cmdutil.UsageError("Too many arguments.")
+    cmp_list = []
+    if options.sort_by != None:
+        for cmp in options.sort_by.split(','):
+            if cmp not in AVAILABLE_CMPS:
+                raise cmdutil.UserError(
+                    "Invalid sort on '%s'.\nValid sorts:\n  %s"
+                    % (cmp, '\n  '.join(AVAILABLE_CMPS)))
+            cmp_list.append(eval('bug.cmp_%s' % cmp))
+    
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bd.load_all_bugs()
+    # select status
+    if options.status != None:
+        if options.status == "all":
+            status = bug.status_values
+        else:
+            status = options.status.split(',')
+    else:
+        status = []
+        if options.active == True:
+            status.extend(list(bug.active_status_values))
+        if options.unconfirmed == True:
+            status.append("unconfirmed")
+        if options.open == True:
+            status.append("opened")
+        if options.test == True:
+            status.append("test")
+        if status == []: # set the default value
+            status = bug.active_status_values
+    # select severity
+    if options.severity != None:
+        if options.severity == "all":
+            severity = bug.severity_values
+        else:
+            severity = options.severity.split(',')
+    else:
+        severity = []
+        if options.wishlist == True:
+            severity.extend("wishlist")
+        if options.important == True:
+            serious = bug.severity_values.index("serious")
+            severity.append(list(bug.severity_values[serious:]))
+        if severity == []: # set the default value
+            severity = bug.severity_values
+    # select assigned
+    if options.assigned != None:
+        if options.assigned == "all":
+            assigned = "all"
+        else:
+            assigned = options.assigned.split(',')
+    else:
+        assigned = []
+        if options.mine == True:
+            assigned.extend('-')
+        if assigned == []: # set the default value
+            assigned = "all"
+    for i in range(len(assigned)):
+        if assigned[i] == '-':
+            assigned[i] = bd.user_id
+    # select target
+    if options.target != None:
+        if options.target == "all":
+            target = "all"
+        else:
+            target = options.target.split(',')
+    else:
+        target = []
+        if options.cur_target == True:
+            target.append(bd.target)
+        if target == []: # set the default value
+            target = "all"
+    if options.extra_strings != None:
+        extra_string_regexps = [re.compile(x) for x in options.extra_strings.split(',')]
+
+    def filter(bug):
+        if status != "all" and not bug.status in status:
+            return False
+        if severity != "all" and not bug.severity in severity:
+            return False
+        if assigned != "all" and not bug.assigned in assigned:
+            return False
+        if target != "all" and not bug.target in target:
+            return False
+        if options.extra_strings != None:
+            if len(bug.extra_strings) == 0 and len(extra_string_regexps) > 0:
+                return False
+            for string in bug.extra_strings:
+                for regexp in extra_string_regexps:
+                    if not regexp.match(string):
+                        return False
+        return True
+
+    bugs = [b for b in bd if filter(b) ]
+    if len(bugs) == 0 and options.xml == False:
+        print "No matching bugs found"
+    
+    def list_bugs(cur_bugs, title=None, just_uuids=False, xml=False):
+        if xml == True:
+            print '<?xml version="1.0" encoding="%s" ?>' % bd.encoding
+            print "<bugs>"
+        if len(cur_bugs) > 0:
+            if title != None and xml == False:
+                print cmdutil.underlined(title)
+            for bg in cur_bugs:
+                if xml == True:
+                    print bg.xml(show_comments=True)
+                elif just_uuids:
+                    print bg.uuid
+                else:
+                    print bg.string(shortlist=True)
+        if xml == True:
+            print "</bugs>"
+
+    # sort bugs
+    cmp_list.extend(bug.DEFAULT_CMP_FULL_CMP_LIST)
+    cmp_fn = bug.BugCompoundComparator(cmp_list=cmp_list)
+    bugs.sort(cmp_fn)
+
+    # print list of bugs
+    list_bugs(bugs, just_uuids=options.uuids, xml=options.xml)
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be list [options]")
+    parser.add_option("-s", "--status", metavar="STATUS", dest="status",
+                      help="List bugs matching STATUS", default=None)
+    parser.add_option("-v", "--severity", metavar="SEVERITY", dest="severity",
+                      help="List bugs matching SEVERITY", default=None)
+    parser.add_option("-a", "--assigned", metavar="ASSIGNED", dest="assigned",
+                      help="List bugs matching ASSIGNED", default=None)
+    parser.add_option("-t", "--target", metavar="TARGET", dest="target",
+                      help="List bugs matching TARGET", default=None)
+    parser.add_option("-e", "--extra-strings", metavar="STRINGS", dest="extra_strings",
+                      help="List bugs matching _all_ extra strings in comma-seperated list STRINGS.  e.g. --extra-strings TAG:working,TAG:xml", default=None)
+    parser.add_option("-S", "--sort", metavar="SORT-BY", dest="sort_by",
+                      help="Adjust bug-sort criteria with comma-separated list SORT-BY.  e.g. \"--sort creator,time\".  Available criteria: %s" % ','.join(AVAILABLE_CMPS), default=None)
+    # boolean options.  All but uuids and xml are special cases of long forms
+    bools = (("u", "uuids", "Only print the bug UUIDS"),
+             ("w", "wishlist", "List bugs with 'wishlist' severity"),
+             ("i", "important", "List bugs with >= 'serious' severity"),
+             ("A", "active", "List all active bugs"),
+             ("U", "unconfirmed", "List unconfirmed bugs"),
+             ("o", "open", "List open bugs"),
+             ("T", "test", "List bugs in testing"),
+             ("m", "mine", "List bugs assigned to you"),
+             ("c", "cur-target", "List bugs for the current target"),
+             ("x", "xml", "Dump as XML"))
+    for s in bools:
+        attr = s[1].replace('-','_')
+        short = "-%c" % s[0]
+        long = "--%s" % s[1]
+        help = s[2]
+        parser.add_option(short, long, action="store_true",
+                          dest=attr, help=help)
+    return parser
+
+
+def help():
+    longhelp="""
+This command lists bugs.  Normally it prints a short string like
+  576:om: Allow attachments
+Where
+  576   the bug id
+  o     the bug status is 'open' (first letter)
+  m     the bug severity is 'minor' (first letter)
+  Allo... the bug summary string
+
+You can optionally (-u) print only the bug ids.
+
+There are several criteria that you can filter by:
+  * status
+  * severity
+  * assigned (who the bug is assigned to)
+  * target   (bugfix deadline)
+Allowed values for each criterion may be given in a comma seperated
+list.  The special string "all" may be used with any of these options
+to match all values of the criterion.
+
+status
+  %s
+severity
+  %s
+assigned
+  free form, with the string '-' being a shortcut for yourself.
+target
+  free form
+
+In addition, there are some shortcut options that set boolean flags.
+The boolean options are ignored if the matching string option is used.
+""" % (','.join(bug.status_values),
+       ','.join(bug.severity_values))
+    return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+    for option, value in cmdutil.option_value_pairs(options, parser):
+        if value == "--complete":
+            if option == "status":
+                raise cmdutil.GetCompletions(bug.status_values)
+            elif option == "severity":
+                raise cmdutil.GetCompletions(bug.severity_values)
+            raise cmdutil.GetCompletions()
+    if "--complete" in args:
+        raise cmdutil.GetCompletions() # no positional arguments for list
diff --git a/interfaces/email/interactive/becommands/merge.py b/interfaces/email/interactive/becommands/merge.py
new file mode 100644 (file)
index 0000000..f212b01
--- /dev/null
@@ -0,0 +1,165 @@
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Merge duplicate bugs"""
+from libbe import cmdutil, bugdir
+import os, copy
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> from libbe import utility
+    >>> bd = bugdir.SimpleBugDir()
+    >>> bd.set_sync_with_disk(True)
+    >>> a = bd.bug_from_shortname("a")
+    >>> a.comment_root.time = 0
+    >>> dummy = a.new_comment("Testing")
+    >>> dummy.time = 1
+    >>> dummy = dummy.new_reply("Testing...")
+    >>> dummy.time = 2
+    >>> b = bd.bug_from_shortname("b")
+    >>> b.status = "open"
+    >>> b.comment_root.time = 0
+    >>> dummy = b.new_comment("1 2")
+    >>> dummy.time = 1
+    >>> dummy = dummy.new_reply("1 2 3 4")
+    >>> dummy.time = 2
+    >>> os.chdir(bd.root)
+    >>> execute(["a", "b"], manipulate_encodings=False)
+    Merging bugs a and b
+    >>> bd._clear_bugs()
+    >>> a = bd.bug_from_shortname("a")
+    >>> a.load_comments()
+    >>> mergeA = a.comment_from_shortname(":3")
+    >>> mergeA.time = 3
+    >>> print a.string(show_comments=True) # doctest: +ELLIPSIS
+              ID : a
+      Short name : a
+        Severity : minor
+          Status : open
+        Assigned : 
+          Target : 
+        Reporter : 
+         Creator : John Doe <jdoe@example.com>
+         Created : ...
+    Bug A
+    --------- Comment ---------
+    Name: a:1
+    From: ...
+    Date: ...
+    <BLANKLINE>
+    Testing
+      --------- Comment ---------
+      Name: a:2
+      From: ...
+      Date: ...
+    <BLANKLINE>
+      Testing...
+    --------- Comment ---------
+    Name: a:3
+    From: ...
+    Date: ...
+    <BLANKLINE>
+    Merged from bug b
+      --------- Comment ---------
+      Name: a:4
+      From: ...
+      Date: ...
+    <BLANKLINE>
+      1 2
+        --------- Comment ---------
+        Name: a:5
+        From: ...
+        Date: ...
+    <BLANKLINE>
+        1 2 3 4
+    >>> b = bd.bug_from_shortname("b")
+    >>> b.load_comments()
+    >>> mergeB = b.comment_from_shortname(":3")
+    >>> mergeB.time = 3
+    >>> print b.string(show_comments=True) # doctest: +ELLIPSIS
+              ID : b
+      Short name : b
+        Severity : minor
+          Status : closed
+        Assigned : 
+          Target : 
+        Reporter : 
+         Creator : Jane Doe <jdoe@example.com>
+         Created : ...
+    Bug B
+    --------- Comment ---------
+    Name: b:1
+    From: ...
+    Date: ...
+    <BLANKLINE>
+    1 2
+      --------- Comment ---------
+      Name: b:2
+      From: ...
+      Date: ...
+    <BLANKLINE>
+      1 2 3 4
+    --------- Comment ---------
+    Name: b:3
+    From: ...
+    Date: ...
+    <BLANKLINE>
+    Merged into bug a
+    >>> print b.status
+    closed
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True,
+                                         1: lambda bug : bug.active==True})
+
+    if len(args) < 2:
+        raise cmdutil.UsageError("Please specify two bug ids.")
+    if len(args) > 2:
+        help()
+        raise cmdutil.UsageError("Too many arguments.")
+    
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bugA = cmdutil.bug_from_shortname(bd, args[0])
+    bugA.load_comments()
+    bugB = cmdutil.bug_from_shortname(bd, args[1])
+    bugB.load_comments()
+    mergeA = bugA.new_comment("Merged from bug %s" % bugB.uuid)
+    newCommTree = copy.deepcopy(bugB.comment_root)
+    for comment in newCommTree.traverse(): # all descendant comments
+        comment.bug = bugA
+        comment.save() # force onto disk under bugA
+    for comment in newCommTree: # just the child comments
+        mergeA.add_reply(comment, allow_time_inversion=True)
+    bugB.new_comment("Merged into bug %s" % bugA.uuid)
+    bugB.status = "closed"
+    print "Merging bugs %s and %s" % (bugA.uuid, bugB.uuid)
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be merge BUG-ID BUG-ID")
+    return parser
+
+longhelp="""
+The second bug (B) is merged into the first (A).  This adds merge
+comments to both bugs, closes B, and appends B's comment tree to A's
+merge comment.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/new.py b/interfaces/email/interactive/becommands/new.py
new file mode 100644 (file)
index 0000000..a8ee2ec
--- /dev/null
@@ -0,0 +1,80 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Create a new bug"""
+from libbe import cmdutil, bugdir
+import sys
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os, time
+    >>> from libbe import bug
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> bug.uuid_gen = lambda: "X"
+    >>> execute (["this is a test",], manipulate_encodings=False)
+    Created bug with ID X
+    >>> bd._clear_bugs()
+    >>> bug = bd.bug_from_uuid("X")
+    >>> print bug.summary
+    this is a test
+    >>> bug.time <= int(time.time())
+    True
+    >>> print bug.severity
+    minor
+    >>> bug.target == None
+    True
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser)
+    if len(args) != 1:
+        raise cmdutil.UsageError("Please supply a summary message")
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if args[0] == '-': # read summary from stdin
+        summary = sys.stdin.readline()
+    else:
+        summary = args[0]
+    bug = bd.new_bug(summary=summary.strip())
+    if options.reporter != None:
+        bug.reporter = options.reporter
+    else:
+        bug.reporter = bug.creator
+    if options.assigned != None:
+        bug.assigned = options.assigned
+    elif bd.default_assignee != None:
+        bug.assigned = bd.default_assignee
+    print "Created bug with ID %s" % bd.bug_shortname(bug)
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be new SUMMARY")
+    parser.add_option("-r", "--reporter", metavar="REPORTER", dest="reporter",
+                      help="The user who reported the bug", default=None)
+    parser.add_option("-a", "--assigned", metavar="ASSIGNED", dest="assigned",
+                      help="The developer in charge of the bug", default=None)
+    return parser
+
+longhelp="""
+Create a new bug, with a new ID.  The summary specified on the
+commandline is a string (only one line) that describes the bug briefly
+or "-", in which case the string will be read from stdin.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/open.py b/interfaces/email/interactive/becommands/open.py
new file mode 100644 (file)
index 0000000..0c6bf05
--- /dev/null
@@ -0,0 +1,58 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Marien Zwart <marienz@gentoo.org>
+#                         Thomas Gerigk <tgerigk@gmx.de>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Re-open a bug"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> print bd.bug_from_shortname("b").status
+    closed
+    >>> execute(["b"], manipulate_encodings=False)
+    >>> bd._clear_bugs()
+    >>> print bd.bug_from_shortname("b").status
+    open
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==False})
+    if len(args) == 0:
+        raise cmdutil.UsageError, "Please specify a bug id."
+    if len(args) > 1:
+        raise cmdutil.UsageError, "Too many arguments."
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
+    bug.status = "open"
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be open BUG-ID")
+    return parser
+
+longhelp="""
+Mark a bug as 'open'.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/remove.py b/interfaces/email/interactive/becommands/remove.py
new file mode 100644 (file)
index 0000000..8d85033
--- /dev/null
@@ -0,0 +1,62 @@
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Remove (delete) a bug and its comments"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> from libbe import mapfile
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> print bd.bug_from_shortname("b").status
+    closed
+    >>> execute (["b"], manipulate_encodings=False)
+    Removed bug b
+    >>> bd._clear_bugs()
+    >>> try:
+    ...     bd.bug_from_shortname("b")
+    ... except bugdir.NoBugMatches:
+    ...     print "Bug not found"
+    Bug not found
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True})
+    if len(args) != 1:
+        raise cmdutil.UsageError, "Please specify a bug id."
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
+    bd.remove_bug(bug)
+    print "Removed bug %s" % bug.uuid
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be remove BUG-ID")
+    return parser
+
+longhelp="""
+Remove (delete) an existing bug.  Use with caution: if you're not using a
+revision control system, there may be no way to recover the lost information.
+You should use this command, for example, to get rid of blank or otherwise 
+mangled bugs.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/set.py b/interfaces/email/interactive/becommands/set.py
new file mode 100644 (file)
index 0000000..f7e68d3
--- /dev/null
@@ -0,0 +1,130 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Chris Ball <cjb@laptop.org>
+#                         Marien Zwart <marienz@gentoo.org>
+#                         Thomas Gerigk <tgerigk@gmx.de>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Change tree settings"""
+import textwrap
+from libbe import cmdutil, bugdir, vcs, settings_object
+__desc__ = __doc__
+
+def _value_string(bd, setting):
+    val = bd.settings.get(setting, settings_object.EMPTY)
+    if val == settings_object.EMPTY:
+        default = getattr(bd, bd._setting_name_to_attr_name(setting))
+        if default not in [None, settings_object.EMPTY]:
+            val = "None (%s)" % default
+        else:
+            val = None
+    return str(val)
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> execute(["target"], manipulate_encodings=False)
+    None
+    >>> execute(["target", "tomorrow"], manipulate_encodings=False)
+    >>> execute(["target"], manipulate_encodings=False)
+    tomorrow
+    >>> execute(["target", "none"], manipulate_encodings=False)
+    >>> execute(["target"], manipulate_encodings=False)
+    None
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    complete(options, args, parser)
+    if len(args) > 2:
+        raise cmdutil.UsageError, "Too many arguments"
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if len(args) == 0:
+        keys = bd.settings_properties
+        keys.sort()
+        for key in keys:
+            print "%16s: %s" % (key, _value_string(bd, key))
+    elif len(args) == 1:
+        print _value_string(bd, args[0])
+    else:
+        if args[1] == "none":
+            setattr(bd, args[0], settings_object.EMPTY)
+        else:
+            if args[0] not in bd.settings_properties:
+                msg = "Invalid setting %s\n" % args[0]
+                msg += 'Allowed settings:\n  '
+                msg += '\n  '.join(bd.settings_properties)
+                raise cmdutil.UserError(msg)
+            old_setting = bd.settings.get(args[0])
+            setattr(bd, args[0], args[1])
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be set [NAME] [VALUE]")
+    return parser
+
+def get_bugdir_settings():
+    settings = []
+    for s in bugdir.BugDir.settings_properties:
+        settings.append(s)
+    settings.sort()
+    documented_settings = []
+    for s in settings:
+        set = getattr(bugdir.BugDir, s)
+        dstr = set.__doc__.strip()
+        # per-setting comment adjustments
+        if s == "vcs_name":
+            lines = dstr.split('\n')
+            while lines[0].startswith("This property defaults to") == False:
+                lines.pop(0)
+            assert len(lines) != None, \
+                "Unexpected vcs_name docstring:\n  '%s'" % dstr
+            lines.insert(
+                0, "The name of the revision control system to use.\n")
+            dstr = '\n'.join(lines)
+        doc = textwrap.wrap(dstr, width=70, initial_indent='  ',
+                            subsequent_indent='  ')
+        documented_settings.append("%s\n%s" % (s, '\n'.join(doc)))
+    return documented_settings
+
+longhelp="""
+Show or change per-tree settings. 
+
+If name and value are supplied, the name is set to a new value.
+If no value is specified, the current value is printed.
+If no arguments are provided, all names and values are listed. 
+
+To unset a setting, set it to "none".
+
+Allowed settings are:
+
+%s""" % ('\n'.join(get_bugdir_settings()),)
+
+def help():
+    return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+    for option, value in cmdutil.option_value_pairs(options, parser):
+        if value == "--complete":
+            # no argument-options at the moment, so this is future-proofing
+            raise cmdutil.GetCompletions()
+    for pos,value in enumerate(args):
+        if value == "--complete":
+            if pos == 0: # first positional argument is a setting name
+                props = bugdir.BugDir.settings_properties
+                raise cmdutil.GetCompletions(props)
+            raise cmdutil.GetCompletions() # no positional arguments for list
diff --git a/interfaces/email/interactive/becommands/severity.py b/interfaces/email/interactive/becommands/severity.py
new file mode 100644 (file)
index 0000000..660586e
--- /dev/null
@@ -0,0 +1,103 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Marien Zwart <marienz@gentoo.org>
+#                         Thomas Gerigk <tgerigk@gmx.de>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Show or change a bug's severity level"""
+from libbe import cmdutil, bugdir, bug
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> execute(["a"], manipulate_encodings=False)
+    minor
+    >>> execute(["a", "wishlist"], manipulate_encodings=False)
+    >>> execute(["a"], manipulate_encodings=False)
+    wishlist
+    >>> execute(["a", "none"], manipulate_encodings=False)
+    Traceback (most recent call last):
+    UserError: Invalid severity level: none
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    complete(options, args, parser)
+    if len(args) not in (1,2):
+        raise cmdutil.UsageError
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
+    if len(args) == 1:
+        print bug.severity
+    elif len(args) == 2:
+        try:
+            bug.severity = args[1]
+        except ValueError, e:
+            if e.name != "severity":
+                raise e
+            raise cmdutil.UserError ("Invalid severity level: %s" % e.value)
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be severity BUG-ID [SEVERITY]")
+    return parser
+
+def help():
+    longhelp=["""
+Show or change a bug's severity level.
+
+If no severity is specified, the current value is printed.  If a severity level
+is specified, it will be assigned to the bug.
+
+Severity levels are:
+"""]
+    try: # See if there are any per-tree severity configurations
+        bd = bugdir.BugDir(from_disk=True, manipulate_encodings=False)
+    except bugdir.NoBugDir, e:
+        pass # No tree, just show the defaults
+    longest_severity_len = max([len(s) for s in bug.severity_values])
+    for severity in bug.severity_values :
+        description = bug.severity_description[severity]
+        s = "%*s : %s\n" % (longest_severity_len, severity, description)
+        longhelp.append(s)
+    longhelp = ''.join(longhelp)
+    return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+    for option,value in cmdutil.option_value_pairs(options, parser):
+        if value == "--complete":
+            # no argument-options at the moment, so this is future-proofing
+            raise cmdutil.GetCompletions()
+    for pos,value in enumerate(args):
+        if value == "--complete":
+            try: # See if there are any per-tree severity configurations
+                bd = bugdir.BugDir(from_disk=True,
+                                   manipulate_encodings=False)
+            except bugdir.NoBugDir:
+                bd = None
+            if pos == 0: # fist positional argument is a bug id 
+                ids = []
+                if bd != None:
+                    bd.load_all_bugs()
+                    filter = lambda bg : bg.active==True
+                    bugs = [bg for bg in bd if filter(bg)==True]
+                    ids = [bd.bug_shortname(bg) for bg in bugs]
+                raise cmdutil.GetCompletions(ids)
+            elif pos == 1: # second positional argument is a severity
+                raise cmdutil.GetCompletions(bug.severity_values)
+            raise cmdutil.GetCompletions()
diff --git a/interfaces/email/interactive/becommands/show.py b/interfaces/email/interactive/becommands/show.py
new file mode 100644 (file)
index 0000000..50bd6eb
--- /dev/null
@@ -0,0 +1,116 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Chris Ball <cjb@laptop.org>
+#                         Thomas Gerigk <tgerigk@gmx.de>
+#                         Thomas Habets <thomas@habets.pp.se>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Show a particular bug"""
+import sys
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> execute (["a",], manipulate_encodings=False) # doctest: +ELLIPSIS
+              ID : a
+      Short name : a
+        Severity : minor
+          Status : open
+        Assigned : 
+          Target : 
+        Reporter : 
+         Creator : John Doe <jdoe@example.com>
+         Created : ...
+    Bug A
+    <BLANKLINE>
+    >>> execute (["--xml", "a"], manipulate_encodings=False) # doctest: +ELLIPSIS
+    <?xml version="1.0" encoding="..." ?>
+    <bug>
+      <uuid>a</uuid>
+      <short-name>a</short-name>
+      <severity>minor</severity>
+      <status>open</status>
+      <creator>John Doe &lt;jdoe@example.com&gt;</creator>
+      <created>...</created>
+      <summary>Bug A</summary>
+    </bug>
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={-1: lambda bug : bug.active==True})
+    if len(args) == 0:
+        raise cmdutil.UsageError
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if options.XML:
+        print '<?xml version="1.0" encoding="%s" ?>' % bd.encoding
+    for shortname in args:
+        if shortname.count(':') > 1:
+            raise cmdutil.UserError("Invalid id '%s'." % shortname)        
+        elif shortname.count(':') == 1:
+            # Split shortname generated by Comment.comment_shortnames()
+            bugname = shortname.split(':')[0]
+            is_comment = True
+        else:
+            bugname = shortname
+            is_comment = False
+        if is_comment == True and options.comments == False:
+            continue
+        bug = cmdutil.bug_from_shortname(bd, bugname)
+        if is_comment == False:
+            if options.XML:
+                print bug.xml(show_comments=options.comments)
+            else:
+                print bug.string(show_comments=options.comments)
+        else:
+            comment = bug.comment_root.comment_from_shortname(
+                shortname, bug_shortname=bugname)
+            if options.XML:
+                print comment.xml(shortname=shortname)
+            else:
+                if len(args) == 1 and options.only_raw_body == True:
+                    sys.__stdout__.write(comment.body)
+                else:
+                    print comment.string(shortname=shortname)
+        if shortname != args[-1] and options.XML == False:
+            print "" # add a blank line between bugs/comments
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be show [options] ID [ID ...]")
+    parser.add_option("-x", "--xml", action="store_true", default=False,
+                      dest='XML', help="Dump as XML")
+    parser.add_option("--only-raw-body", action="store_true",
+                      dest='only_raw_body',
+                      help="When printing only a single comment, just print it's body.  This allows extraction of non-text content types.")
+    parser.add_option("-c", "--no-comments", dest="comments",
+                      action="store_false", default=True,
+                      help="Disable comment output.  This is useful if you just want more details on a bug's current status.")
+    return parser
+
+longhelp="""
+Show all information about the bugs or comments whose IDs are given.
+
+It's probably not a good idea to mix bug and comment IDs in a single
+call, but you're free to do so if you like.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/status.py b/interfaces/email/interactive/becommands/status.py
new file mode 100644 (file)
index 0000000..f315003
--- /dev/null
@@ -0,0 +1,115 @@
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Show or change a bug's status"""
+from libbe import cmdutil, bugdir, bug
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> execute(["a"], manipulate_encodings=False)
+    open
+    >>> execute(["a", "closed"], manipulate_encodings=False)
+    >>> execute(["a"], manipulate_encodings=False)
+    closed
+    >>> execute(["a", "none"], manipulate_encodings=False)
+    Traceback (most recent call last):
+    UserError: Invalid status: none
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    complete(options, args, parser)
+    if len(args) not in (1,2):
+        raise cmdutil.UsageError
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    bug = cmdutil.bug_from_shortname(bd, args[0])
+    if len(args) == 1:
+        print bug.status
+    else:
+        try:
+            bug.status = args[1]
+        except ValueError, e:
+            if e.name != "status":
+                raise
+            raise cmdutil.UserError ("Invalid status: %s" % e.value)
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be status BUG-ID [STATUS]")
+    return parser
+
+
+def help():
+    try: # See if there are any per-tree status configurations
+        bd = bugdir.BugDir(from_disk=True,
+                           manipulate_encodings=False)
+    except bugdir.NoBugDir, e:
+        pass # No tree, just show the defaults
+    longest_status_len = max([len(s) for s in bug.status_values])
+    active_statuses = []
+    for status in bug.active_status_values :
+        description = bug.status_description[status]
+        s = "%*s : %s" % (longest_status_len, status, description)
+        active_statuses.append(s)
+    inactive_statuses = []
+    for status in bug.inactive_status_values :
+        description = bug.status_description[status]
+        s = "%*s : %s" % (longest_status_len, status, description)
+        inactive_statuses.append(s)
+    longhelp="""
+Show or change a bug's status.
+
+If no status is specified, the current value is printed.  If a status
+is specified, it will be assigned to the bug.
+
+There are two classes of statuses, active and inactive, which are only
+important for commands like "be list" that show only active bugs by
+default.
+
+Active status levels are:
+  %s
+Inactive status levels are:
+  %s
+
+You can overide the list of allowed statuses on a per-repository basis.
+See "be set --help" for more details.
+""" % ('\n  '.join(active_statuses), '\n  '.join(inactive_statuses))
+    return get_parser().help_str() + longhelp
+
+def complete(options, args, parser):
+    for option,value in cmdutil.option_value_pairs(options, parser):
+        if value == "--complete":
+            # no argument-options at the moment, so this is future-proofing
+            raise cmdutil.GetCompletions()
+    for pos,value in enumerate(args):
+        if value == "--complete":
+            try: # See if there are any per-tree status configurations
+                bd = bugdir.BugDir(from_disk=True,
+                                   manipulate_encodings=False)
+            except bugdir.NoBugDir:
+                bd = None
+            if pos == 0: # fist positional argument is a bug id 
+                ids = []
+                if bd != None:
+                    bd.load_all_bugs()
+                    ids = [bd.bug_shortname(bg) for bg in bd]
+                raise cmdutil.GetCompletions(ids)
+            elif pos == 1: # second positional argument is a status
+                raise cmdutil.GetCompletions(bug.status_values)
+            raise cmdutil.GetCompletions()
diff --git a/interfaces/email/interactive/becommands/subscribe.py b/interfaces/email/interactive/becommands/subscribe.py
new file mode 100644 (file)
index 0000000..0a23057
--- /dev/null
@@ -0,0 +1,390 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""(Un)subscribe to change notification"""
+from libbe import cmdutil, bugdir, tree
+import os, copy
+__desc__ = __doc__
+
+TAG="SUBSCRIBE:"
+
+class SubscriptionType (tree.Tree):
+    """
+    Trees of subscription types to allow users to select exactly what
+    notifications they want to subscribe to.
+    """
+    def __init__(self, type_name, *args, **kwargs):
+        tree.Tree.__init__(self, *args, **kwargs)
+        self.type = type_name
+    def __str__(self):
+        return self.type
+    def __repr__(self):
+        return "<SubscriptionType: %s>" % str(self)
+    def string_tree(self, indent=0):
+        lines = []
+        for depth,node in self.thread():
+            lines.append("%s%s" % (" "*(indent+2*depth), node))
+        return "\n".join(lines)
+
+BUGDIR_TYPE_NEW = SubscriptionType("new")
+BUGDIR_TYPE_ALL = SubscriptionType("all", [BUGDIR_TYPE_NEW])
+
+# same name as BUGDIR_TYPE_ALL for consistency
+BUG_TYPE_ALL = SubscriptionType(str(BUGDIR_TYPE_ALL))
+
+INVALID_TYPE = SubscriptionType("INVALID")
+
+class InvalidType (ValueError):
+    def __init__(self, type_name, type_root):
+        msg = "Invalid type %s for tree:\n%s" \
+            % (type_name, type_root.string_tree(4))
+        ValueError.__init__(self, msg)
+        self.type_name = type_name
+        self.type_root = type_root
+
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> bd = bugdir.SimpleBugDir()
+    >>> bd.set_sync_with_disk(True)
+    >>> os.chdir(bd.root)
+    >>> a = bd.bug_from_shortname("a")
+    >>> print a.extra_strings
+    []
+    >>> execute(["-s","John Doe <j@doe.com>", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    John Doe <j@doe.com>    all    *
+    >>> bd._clear_bugs() # resync our copy of bug
+    >>> a = bd.bug_from_shortname("a")
+    >>> print a.extra_strings
+    ['SUBSCRIBE:John Doe <j@doe.com>\\tall\\t*']
+    >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "a.com,b.net", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    Jane Doe <J@doe.com>    all    a.com,b.net
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "a.edu", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    Jane Doe <J@doe.com>    all    a.com,a.edu,b.net
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-u", "-s","Jane Doe <J@doe.com>", "-S", "a.com", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    Jane Doe <J@doe.com>    all    a.edu,b.net
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-s","Jane Doe <J@doe.com>", "-S", "*", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    Jane Doe <J@doe.com>    all    *
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-u", "-s","Jane Doe <J@doe.com>", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for a:
+    John Doe <j@doe.com>    all    *
+    >>> execute(["-u", "-s","John Doe <j@doe.com>", "a"], manipulate_encodings=False)
+    >>> execute(["-s","Jane Doe <J@doe.com>", "-t", "new", "DIR"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for bug directory:
+    Jane Doe <J@doe.com>    new    *
+    >>> execute(["-s","Jane Doe <J@doe.com>", "DIR"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE
+    Subscriptions for bug directory:
+    Jane Doe <J@doe.com>    all    *
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True})
+
+    if len(args) > 1:
+        help()
+        raise cmdutil.UsageError("Too many arguments.")
+
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+
+    subscriber = options.subscriber
+    if subscriber == None:
+        subscriber = bd.user_id
+    if options.unsubscribe == True:
+        if options.servers == None:
+            options.servers = "INVALID"
+        if options.types == None:
+            options.types = "INVALID"
+    else:
+        if options.servers == None:
+            options.servers = "*"
+        if options.types == None:
+            options.types = "all"
+    servers = options.servers.split(",")
+    types = options.types.split(",")
+
+    if len(args) == 0 or args[0] == "DIR": # directory-wide subscriptions
+        type_root = BUGDIR_TYPE_ALL
+        entity = bd
+        entity_name = "bug directory"
+    else: # bug-specific subscriptions
+        type_root = BUG_TYPE_ALL
+        bug = bd.bug_from_shortname(args[0])
+        entity = bug
+        entity_name = bug.uuid
+    if options.list_all == True:
+        entity_name = "anything in the bug directory"
+
+    types = [type_from_name(name, type_root, default=INVALID_TYPE,
+                            default_ok=options.unsubscribe)
+             for name in types]
+    estrs = entity.extra_strings
+    if options.list == True or options.list_all == True:
+        pass
+    else: # alter subscriptions
+        if options.unsubscribe == True:
+            estrs = unsubscribe(estrs, subscriber, types, servers, type_root)
+        else: # add the tag
+            estrs = subscribe(estrs, subscriber, types, servers, type_root)
+        entity.extra_strings = estrs # reassign to notice change
+
+    if options.list_all == True:
+        bd.load_all_bugs()
+        subscriptions = get_bugdir_subscribers(bd, servers[0])
+    else:
+        subscriptions = []
+        for estr in entity.extra_strings:
+            if estr.startswith(TAG):
+                subscriptions.append(estr[len(TAG):])
+
+    if len(subscriptions) > 0:
+        print "Subscriptions for %s:" % entity_name
+        print '\n'.join(subscriptions)
+
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be subscribe ID")
+    parser.add_option("-u", "--unsubscribe", action="store_true",
+                      dest="unsubscribe", default=False,
+                      help="Unsubscribe instead of subscribing.")
+    parser.add_option("-a", "--list-all", action="store_true",
+                      dest="list_all", default=False,
+                      help="List all subscribers (no ID argument, read only action).")
+    parser.add_option("-l", "--list", action="store_true",
+                      dest="list", default=False,
+                      help="List subscribers (read only action).")
+    parser.add_option("-s", "--subscriber", dest="subscriber",
+                      metavar="SUBSCRIBER",
+                      help="Email address of the subscriber (defaults to bugdir.user_id).")
+    parser.add_option("-S", "--servers", dest="servers", metavar="SERVERS",
+                      help="Servers from which you want notification.")
+    parser.add_option("-t", "--type", dest="types", metavar="TYPES",
+                      help="Types of changes you wish to be notified about.")
+    return parser
+
+longhelp="""
+ID can be either a bug id, or blank/"DIR", in which case it refers to the
+whole bug directory.
+
+SERVERS specifies the servers from which you would like to receive
+notification.  Multiple severs may be specified in a comma-separated
+list, or you can use "*" to match all servers (the default).  If you
+have not selected a server, it should politely refrain from notifying
+you of changes, although there is no way to guarantee this behavior.
+
+Available TYPES:
+  For bugs:
+%s
+  For DIR :
+%s
+
+For unsubscription, any listed SERVERS and TYPES are removed from your
+subscription.  Either the catch-all server "*" or type "%s" will
+remove SUBSCRIBER entirely from the specified ID.
+
+This command is intended for use primarily by public interfaces, since
+if you're just hacking away on your private repository, you'll known
+what's changed ;).  This command just (un)sets the appropriate
+subscriptions, and leaves it up to each interface to perform the
+notification.
+""" % (BUG_TYPE_ALL.string_tree(6), BUGDIR_TYPE_ALL.string_tree(6),
+       BUGDIR_TYPE_ALL)
+
+def help():
+    return get_parser().help_str() + longhelp
+
+# internal helper functions
+
+def _generate_string(subscriber, types, servers):
+    types = sorted([str(t) for t in types])
+    servers = sorted(servers)
+    return "%s%s\t%s\t%s" % (TAG,subscriber,",".join(types),",".join(servers))
+
+def _parse_string(string, type_root):
+    assert string.startswith(TAG), string
+    string = string[len(TAG):]
+    subscriber,types,servers = string.split("\t")
+    types = [type_from_name(name, type_root) for name in types.split(",")]
+    return (subscriber,types,servers.split(","))
+
+def _get_subscriber(extra_strings, subscriber, type_root):
+    for i,string in enumerate(extra_strings):
+        if string.startswith(TAG):
+            s,ts,srvs = _parse_string(string, type_root)
+            if s == subscriber:
+                return i,s,ts,srvs # match!
+    return None # no match
+
+# functions exposed to other modules
+
+def type_from_name(name, type_root, default=None, default_ok=False):
+    if name == str(type_root):
+        return type_root
+    for t in type_root.traverse():
+        if name == str(t):
+            return t
+    if default_ok:
+        return default
+    raise InvalidType(name, type_root)
+
+def subscribe(extra_strings, subscriber, types, servers, type_root):
+    args = _get_subscriber(extra_strings, subscriber, type_root)
+    if args == None: # no match
+        extra_strings.append(_generate_string(subscriber, types, servers))
+        return extra_strings
+    # Alter matched string
+    i,s,ts,srvs = args
+    for t in types:
+        if t not in ts:
+            ts.append(t)
+    # remove descendant types
+    all_ts = copy.copy(ts)
+    for t in all_ts:
+        for tt in all_ts:
+            if tt in ts and t.has_descendant(tt):
+                ts.remove(tt)
+    if "*" in servers+srvs:
+        srvs = ["*"]
+    else:
+        srvs = list(set(servers+srvs))
+    extra_strings[i] = _generate_string(subscriber, ts, srvs)
+    return extra_strings
+
+def unsubscribe(extra_strings, subscriber, types, servers, type_root):
+    args = _get_subscriber(extra_strings, subscriber, type_root)
+    if args == None: # no match
+        return extra_strings # pass
+    # Remove matched string
+    i,s,ts,srvs = args
+    all_ts = copy.copy(ts)
+    for t in types:
+        for tt in all_ts:
+            if tt in ts and t.has_descendant(tt):
+                ts.remove(tt)
+    if "*" in servers+srvs:
+        srvs = []
+    else:
+        for srv in servers:
+            if srv in srvs:
+                srvs.remove(srv)
+    if len(ts) == 0 or len(srvs) == 0:
+        extra_strings.pop(i)
+    else:
+        extra_strings[i] = _generate_string(subscriber, ts, srvs)
+    return extra_strings
+
+def get_subscribers(extra_strings, type, server, type_root,
+                    match_ancestor_types=False,
+                    match_descendant_types=False):
+    """
+    Set match_ancestor_types=True if you want to find eveyone who
+    cares about your particular type.
+
+    Set match_descendant_types=True if you want to find subscribers
+    who may only care about some subset of your type.  This is useful
+    for generating lists of all the subscribers in a given set of
+    extra_strings.
+
+    >>> def sgs(*args, **kwargs):
+    ...     return sorted(get_subscribers(*args, **kwargs))
+    >>> es = []
+    >>> es = subscribe(es, "John Doe <j@doe.com>", [BUGDIR_TYPE_ALL], ["a.com"], BUGDIR_TYPE_ALL)
+    >>> es = subscribe(es, "Jane Doe <J@doe.com>", [BUGDIR_TYPE_NEW], ["*"], BUGDIR_TYPE_ALL)
+    >>> sgs(es, BUGDIR_TYPE_ALL, "a.com", BUGDIR_TYPE_ALL)
+    ['John Doe <j@doe.com>']
+    >>> sgs(es, BUGDIR_TYPE_ALL, "a.com", BUGDIR_TYPE_ALL, match_descendant_types=True)
+    ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>']
+    >>> sgs(es, BUGDIR_TYPE_ALL, "b.net", BUGDIR_TYPE_ALL, match_descendant_types=True)
+    ['Jane Doe <J@doe.com>']
+    >>> sgs(es, BUGDIR_TYPE_NEW, "a.com", BUGDIR_TYPE_ALL)
+    ['Jane Doe <J@doe.com>']
+    >>> sgs(es, BUGDIR_TYPE_NEW, "a.com", BUGDIR_TYPE_ALL, match_ancestor_types=True)
+    ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>']
+    """
+    for string in extra_strings:
+        if not string.startswith(TAG):
+            continue
+        subscriber,types,servers = _parse_string(string, type_root)
+        type_match = False
+        if type in types:
+            type_match = True
+        if type_match == False and match_ancestor_types == True:
+            for t in types:
+                if t.has_descendant(type):
+                    type_match = True
+                    break
+        if type_match == False and match_descendant_types == True:
+            for t in types:
+                if type.has_descendant(t):
+                    type_match = True
+                    break
+        server_match = False
+        if server in servers or servers == ["*"] or server == "*":
+            server_match = True
+        if type_match == True and server_match == True:
+            yield subscriber
+
+def get_bugdir_subscribers(bugdir, server):
+    """
+    I have a bugdir.  Who cares about it, and what do they care about?
+    Returns a dict of dicts:
+      subscribers[user][id] = types
+    where id is either a bug.uuid (in the case of a bug subscription)
+    or "DIR" (in the case of a bugdir subscription).
+
+    Only checks bugs that are currently in memory, so you might want
+    to call bugdir.load_all_bugs() first.
+
+    >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+    >>> a = bd.bug_from_shortname("a")
+    >>> bd.extra_strings = subscribe(bd.extra_strings, "John Doe <j@doe.com>", [BUGDIR_TYPE_ALL], ["a.com"], BUGDIR_TYPE_ALL)
+    >>> bd.extra_strings = subscribe(bd.extra_strings, "Jane Doe <J@doe.com>", [BUGDIR_TYPE_NEW], ["*"], BUGDIR_TYPE_ALL)
+    >>> a.extra_strings = subscribe(a.extra_strings, "John Doe <j@doe.com>", [BUG_TYPE_ALL], ["a.com"], BUG_TYPE_ALL)
+    >>> subscribers = get_bugdir_subscribers(bd, "a.com")
+    >>> subscribers["Jane Doe <J@doe.com>"]["DIR"]
+    [<SubscriptionType: new>]
+    >>> subscribers["John Doe <j@doe.com>"]["DIR"]
+    [<SubscriptionType: all>]
+    >>> subscribers["John Doe <j@doe.com>"]["a"]
+    [<SubscriptionType: all>]
+    >>> get_bugdir_subscribers(bd, "b.net")
+    {'Jane Doe <J@doe.com>': {'DIR': [<SubscriptionType: new>]}}
+    >>> bd.cleanup()
+    """
+    subscribers = {}
+    for sub in get_subscribers(bugdir.extra_strings, BUGDIR_TYPE_ALL, server,
+                               BUGDIR_TYPE_ALL, match_descendant_types=True):
+        i,s,ts,srvs = _get_subscriber(bugdir.extra_strings,sub,BUGDIR_TYPE_ALL)
+        subscribers[sub] = {"DIR":ts}
+    for bug in bugdir:
+        for sub in get_subscribers(bug.extra_strings, BUG_TYPE_ALL, server,
+                                   BUG_TYPE_ALL, match_descendant_types=True):
+            i,s,ts,srvs = _get_subscriber(bug.extra_strings,sub,BUG_TYPE_ALL)
+            if sub in subscribers:
+                subscribers[sub][bug.uuid] = ts
+            else:
+                subscribers[sub] = {bug.uuid:ts}
+    return subscribers
diff --git a/interfaces/email/interactive/becommands/tag.py b/interfaces/email/interactive/becommands/tag.py
new file mode 100644 (file)
index 0000000..ecd853f
--- /dev/null
@@ -0,0 +1,134 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Tag a bug, or search bugs for tags"""
+from libbe import cmdutil, bugdir
+import os, copy
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> from libbe import utility
+    >>> bd = bugdir.SimpleBugDir()
+    >>> bd.set_sync_with_disk(True)
+    >>> os.chdir(bd.root)
+    >>> a = bd.bug_from_shortname("a")
+    >>> print a.extra_strings
+    []
+    >>> execute(["a", "GUI"], manipulate_encodings=False)
+    Tags for a:
+    GUI
+    >>> bd._clear_bugs() # resync our copy of bug
+    >>> a = bd.bug_from_shortname("a")
+    >>> print a.extra_strings
+    ['TAG:GUI']
+    >>> execute(["a", "later"], manipulate_encodings=False)
+    Tags for a:
+    GUI
+    later
+    >>> execute(["a"], manipulate_encodings=False)
+    Tags for a:
+    GUI
+    later
+    >>> execute(["--list"], manipulate_encodings=False)
+    GUI
+    later
+    >>> execute(["a", "Alphabetically first"], manipulate_encodings=False)
+    Tags for a:
+    Alphabetically first
+    GUI
+    later
+    >>> bd._clear_bugs() # resync our copy of bug
+    >>> a = bd.bug_from_shortname("a")
+    >>> print a.extra_strings
+    ['TAG:Alphabetically first', 'TAG:GUI', 'TAG:later']
+    >>> a.extra_strings = []
+    >>> print a.extra_strings
+    []
+    >>> execute(["a"], manipulate_encodings=False)
+    >>> bd._clear_bugs() # resync our copy of bug
+    >>> a = bd.bug_from_shortname("a")
+    >>> print a.extra_strings
+    []
+    >>> execute(["a", "Alphabetically first"], manipulate_encodings=False)
+    Tags for a:
+    Alphabetically first
+    >>> execute(["--remove", "a", "Alphabetically first"], manipulate_encodings=False)
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True})
+
+    if len(args) == 0 and options.list == False:
+        raise cmdutil.UsageError("Please specify a bug id.")
+    elif len(args) > 2 or (len(args) > 0 and options.list == True):
+        help()
+        raise cmdutil.UsageError("Too many arguments.")
+    
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if options.list:
+        bd.load_all_bugs()
+        tags = []
+        for bug in bd:
+            for estr in bug.extra_strings:
+                if estr.startswith("TAG:"):
+                    tag = estr[4:]
+                    if tag not in tags:
+                        tags.append(tag)
+        tags.sort()
+        if len(tags) > 0:
+            print '\n'.join(tags)
+        return
+    bug = cmdutil.bug_from_shortname(bd, args[0])
+    if len(args) == 2:
+        given_tag = args[1]
+        estrs = bug.extra_strings
+        tag_string = "TAG:%s" % given_tag
+        if options.remove == True:
+            estrs.remove(tag_string)
+        else: # add the tag
+            estrs.append(tag_string)
+        bug.extra_strings = estrs # reassign to notice change
+
+    tags = []
+    for estr in bug.extra_strings:
+        if estr.startswith("TAG:"):
+            tags.append(estr[4:])
+
+    if len(tags) > 0:
+        print "Tags for %s:" % bug.uuid
+        print '\n'.join(tags)
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be tag BUG-ID [TAG]\nor:    be tag --list")
+    parser.add_option("-r", "--remove", action="store_true", dest="remove",
+                      help="Remove TAG (instead of adding it)")
+    parser.add_option("-l", "--list", action="store_true", dest="list",
+                      help="List all available tags and exit")
+    return parser
+
+longhelp="""
+If TAG is given, add TAG to BUG-ID.  If it is not specified, just
+print the tags for BUG-ID.
+
+To search for bugs with a particular tag, try
+  $ be list --extra-strings TAG:<your-tag>
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/becommands/target.py b/interfaces/email/interactive/becommands/target.py
new file mode 100644 (file)
index 0000000..7e41451
--- /dev/null
@@ -0,0 +1,95 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Chris Ball <cjb@laptop.org>
+#                         Gianluca Montecchi <gian@grys.it>
+#                         Marien Zwart <marienz@gentoo.org>
+#                         Thomas Gerigk <tgerigk@gmx.de>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""Show or change a bug's target for fixing"""
+from libbe import cmdutil, bugdir
+__desc__ = __doc__
+
+def execute(args, manipulate_encodings=True):
+    """
+    >>> import os
+    >>> bd = bugdir.SimpleBugDir()
+    >>> os.chdir(bd.root)
+    >>> execute(["a"], manipulate_encodings=False)
+    No target assigned.
+    >>> execute(["a", "tomorrow"], manipulate_encodings=False)
+    >>> execute(["a"], manipulate_encodings=False)
+    tomorrow
+    >>> execute(["--list"], manipulate_encodings=False)
+    tomorrow
+    >>> execute(["a", "none"], manipulate_encodings=False)
+    >>> execute(["a"], manipulate_encodings=False)
+    No target assigned.
+    >>> bd.cleanup()
+    """
+    parser = get_parser()
+    options, args = parser.parse_args(args)
+    cmdutil.default_complete(options, args, parser,
+                             bugid_args={0: lambda bug : bug.active==True})
+                             
+    if len(args) not in (1, 2):
+        if not (options.list == True and len(args) == 0):
+            raise cmdutil.UsageError
+    bd = bugdir.BugDir(from_disk=True,
+                       manipulate_encodings=manipulate_encodings)
+    if options.list:
+        ts = set([bd.bug_from_uuid(bug).target for bug in bd.list_uuids()])
+        for target in sorted(ts):
+            if target and isinstance(target,str):
+                print target
+        return
+    bug = cmdutil.bug_from_shortname(bd, args[0])
+    if len(args) == 1:
+        if bug.target is None:
+            print "No target assigned."
+        else:
+            print bug.target
+    else:
+        assert len(args) == 2
+        if args[1] == "none":
+            bug.target = None
+        else:
+            bug.target = args[1]
+
+def get_parser():
+    parser = cmdutil.CmdOptionParser("be target BUG-ID [TARGET]\nor:    be target --list")
+    parser.add_option("-l", "--list", action="store_true", dest="list",
+                      help="List all available targets and exit")
+    return parser
+
+longhelp="""
+Show or change a bug's target for fixing.  
+
+If no target is specified, the current value is printed.  If a target
+is specified, it will be assigned to the bug.
+
+Targets are freeform; any text may be specified.  They will generally be
+milestone names or release numbers.
+
+The value "none" can be used to unset the target.
+
+In the alternative `be target --list` form print a list of all
+currently specified targets.  Note that bug status
+(i.e. opened/closed) is ignored.  If you want to list all bugs
+matching a current target, see `be list --target TARGET'.
+"""
+
+def help():
+    return get_parser().help_str() + longhelp
diff --git a/interfaces/email/interactive/examples/comment b/interfaces/email/interactive/examples/comment
new file mode 100644 (file)
index 0000000..f22e4b2
--- /dev/null
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <xyz@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug:a1d] Subject ignored
+
+We sure do.
+--
+Goofy tagline ignored
diff --git a/interfaces/email/interactive/examples/failing_multiples b/interfaces/email/interactive/examples/failing_multiples
new file mode 100644 (file)
index 0000000..cf50211
--- /dev/null
@@ -0,0 +1,16 @@
+From jdoe@example.com Fri Apr 18 12:00:00 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Commit message...
+
+new "test bug"
+new "test bug 2"
+failing-command
+new "test bug 3"
+
+--
+This message fails partway through, but the partial changes should be
+recorded in a commit...
diff --git a/interfaces/email/interactive/examples/invalid_command b/interfaces/email/interactive/examples/invalid_command
new file mode 100644 (file)
index 0000000..f2963c7
--- /dev/null
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug]
+
+close
+--
+Close is currently disabled for the email interface.
diff --git a/interfaces/email/interactive/examples/invalid_subject b/interfaces/email/interactive/examples/invalid_subject
new file mode 100644 (file)
index 0000000..1e2eb88
--- /dev/null
@@ -0,0 +1,9 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: Spam!
+
+This should elicit an "invalid subject" response email.
diff --git a/interfaces/email/interactive/examples/list b/interfaces/email/interactive/examples/list
new file mode 100644 (file)
index 0000000..acba424
--- /dev/null
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+list --status all
+--
+Dummy content
diff --git a/interfaces/email/interactive/examples/missing_command b/interfaces/email/interactive/examples/missing_command
new file mode 100644 (file)
index 0000000..bb390fc
--- /dev/null
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+abcde
+--
+This should elicit a "invalid command 'abcde'" response email.
diff --git a/interfaces/email/interactive/examples/multiple_commands b/interfaces/email/interactive/examples/multiple_commands
new file mode 100644 (file)
index 0000000..41ef730
--- /dev/null
@@ -0,0 +1,14 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+help
+list --status=all
+list --status=fixed
+show --xml 361
+--
+Goofy tagline ignored.
diff --git a/interfaces/email/interactive/examples/new b/interfaces/email/interactive/examples/new
new file mode 100644 (file)
index 0000000..c64db93
--- /dev/null
@@ -0,0 +1,19 @@
+From jdoe@example.com Fri Apr 18 12:00:00 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug:submit] Need tests for the email interface.
+
+Version: XYZ
+Reporter: Jane Doe
+Assign: Dick Tracy
+Depend: 00f
+Severity: critical
+Status: assigned
+Tag: topsecret
+Target: Law&Order
+
+--
+Goofy tagline not included, and no comment added.
diff --git a/interfaces/email/interactive/examples/new_with_comment b/interfaces/email/interactive/examples/new_with_comment
new file mode 100644 (file)
index 0000000..1077f0f
--- /dev/null
@@ -0,0 +1,13 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug:submit] Need tests for the email interface.
+
+Version: XYZ
+
+I think so anyway.
+--
+Goofy tagline not included.
diff --git a/interfaces/email/interactive/examples/show b/interfaces/email/interactive/examples/show
new file mode 100644 (file)
index 0000000..c5f8a4d
--- /dev/null
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+show --xml 361
+--
+Can we show a bug?
diff --git a/interfaces/email/interactive/examples/unicode b/interfaces/email/interactive/examples/unicode
new file mode 100644 (file)
index 0000000..f0e8001
--- /dev/null
@@ -0,0 +1,11 @@
+From jdoe@example.com Fri Apr 18 11:18:58 2008
+Message-ID: <abcd@example.com>
+Date: Fri, 18 Apr 2008 12:00:00 +0000
+From: John Doe <jdoe@example.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+Subject: [be-bug] Subject ignored
+
+show --xml f7ccd916-b5c7-4890-a2e3-8c8ace17ae3a
+--
+Can we handle unicode output?
diff --git a/interfaces/email/interactive/libbe/arch.py b/interfaces/email/interactive/libbe/arch.py
new file mode 100644 (file)
index 0000000..ab55172
--- /dev/null
@@ -0,0 +1,312 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Ben Finney <ben+python@benfinney.id.au>
+#                         James Rowe <jnrowe@ukfsn.org>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+GNU Arch (tla) backend.
+"""
+
+import codecs
+import os
+import re
+import shutil
+import sys
+import time
+import unittest
+import doctest
+
+from beuuid import uuid_gen
+import config
+import vcs
+
+
+
+DEFAULT_CLIENT = "tla"
+
+client = config.get_val("arch_client", default=DEFAULT_CLIENT)
+
+def new():
+    return Arch()
+
+class Arch(vcs.VCS):
+    name = "Arch"
+    client = client
+    versioned = True
+    _archive_name = None
+    _archive_dir = None
+    _tmp_archive = False
+    _project_name = None
+    _tmp_project = False
+    _arch_paramdir = os.path.expanduser("~/.arch-params")
+    def _vcs_help(self):
+        status,output,error = self._u_invoke_client("--help")
+        return output
+    def _vcs_detect(self, path):
+        """Detect whether a directory is revision-controlled using Arch"""
+        if self._u_search_parent_directories(path, "{arch}") != None :
+            config.set_val("arch_client", client)
+            return True
+        return False
+    def _vcs_init(self, path):
+        self._create_archive(path)
+        self._create_project(path)
+        self._add_project_code(path)
+    def _create_archive(self, path):
+        """
+        Create a temporary Arch archive in the directory PATH.  This
+        archive will be removed by
+          __del__->cleanup->_vcs_cleanup->_remove_archive
+        """
+        # http://regexps.srparish.net/tutorial-tla/new-archive.html#Creating_a_New_Archive
+        assert self._archive_name == None
+        id = self.get_user_id()
+        name, email = self._u_parse_id(id)
+        if email == None:
+            email = "%s@example.com" % name
+        trailer = "%s-%s" % ("bugs-everywhere-auto", uuid_gen()[0:8])
+        self._archive_name = "%s--%s" % (email, trailer)
+        self._archive_dir = "/tmp/%s" % trailer
+        self._tmp_archive = True
+        self._u_invoke_client("make-archive", self._archive_name,
+                              self._archive_dir, directory=path)
+    def _invoke_client(self, *args, **kwargs):
+        """
+        Invoke the client on our archive.
+        """
+        assert self._archive_name != None
+        command = args[0]
+        if len(args) > 1:
+            tailargs = args[1:]
+        else:
+            tailargs = []
+        arglist = [command, "-A", self._archive_name]
+        arglist.extend(tailargs)
+        args = tuple(arglist)
+        return self._u_invoke_client(*args, **kwargs)
+    def _remove_archive(self):
+        assert self._tmp_archive == True
+        assert self._archive_dir != None
+        assert self._archive_name != None
+        os.remove(os.path.join(self._arch_paramdir,
+                               "=locations", self._archive_name))
+        shutil.rmtree(self._archive_dir)
+        self._tmp_archive = False
+        self._archive_dir = False
+        self._archive_name = False
+    def _create_project(self, path):
+        """
+        Create a temporary Arch project in the directory PATH.  This
+        project will be removed by
+          __del__->cleanup->_vcs_cleanup->_remove_project
+        """
+        # http://mwolson.org/projects/GettingStartedWithArch.html
+        # http://regexps.srparish.net/tutorial-tla/new-project.html#Starting_a_New_Project
+        category = "bugs-everywhere"
+        branch = "mainline"
+        version = "0.1"
+        self._project_name = "%s--%s--%s" % (category, branch, version)
+        self._invoke_client("archive-setup", self._project_name,
+                            directory=path)
+        self._tmp_project = True
+    def _remove_project(self):
+        assert self._tmp_project == True
+        assert self._project_name != None
+        assert self._archive_dir != None
+        shutil.rmtree(os.path.join(self._archive_dir, self._project_name))
+        self._tmp_project = False
+        self._project_name = False
+    def _archive_project_name(self):
+        assert self._archive_name != None
+        assert self._project_name != None
+        return "%s/%s" % (self._archive_name, self._project_name)
+    def _adjust_naming_conventions(self, path):
+        """
+        By default, Arch restricts source code filenames to
+          ^[_=a-zA-Z0-9].*$
+        See
+          http://regexps.srparish.net/tutorial-tla/naming-conventions.html
+        Since our bug directory '.be' doesn't satisfy these conventions,
+        we need to adjust them.
+        
+        The conventions are specified in
+          project-root/{arch}/=tagging-method
+        """
+        tagpath = os.path.join(path, "{arch}", "=tagging-method")
+        lines_out = []
+        f = codecs.open(tagpath, "r", self.encoding)
+        for line in f:
+            if line.startswith("source "):
+                lines_out.append("source ^[._=a-zA-X0-9].*$\n")
+            else:
+                lines_out.append(line)
+        f.close()
+        f = codecs.open(tagpath, "w", self.encoding)
+        f.write("".join(lines_out))
+        f.close()
+
+    def _add_project_code(self, path):
+        # http://mwolson.org/projects/GettingStartedWithArch.html
+        # http://regexps.srparish.net/tutorial-tla/new-source.html
+        # http://regexps.srparish.net/tutorial-tla/importing-first.html
+        self._invoke_client("init-tree", self._project_name,
+                              directory=path)
+        self._adjust_naming_conventions(path)
+        self._invoke_client("import", "--summary", "Began versioning",
+                            directory=path)
+    def _vcs_cleanup(self):
+        if self._tmp_project == True:
+            self._remove_project()
+        if self._tmp_archive == True:
+            self._remove_archive()
+
+    def _vcs_root(self, path):
+        if not os.path.isdir(path):
+            dirname = os.path.dirname(path)
+        else:
+            dirname = path
+        status,output,error = self._u_invoke_client("tree-root", dirname)
+        root = output.rstrip('\n')
+        
+        self._get_archive_project_name(root)
+
+        return root
+    def _get_archive_name(self, root):
+        status,output,error = self._u_invoke_client("archives")
+        lines = output.split('\n')
+        # e.g. output:
+        # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52
+        #     /tmp/BEtestXXXXXX/rootdir
+        # (+ repeats)
+        for archive,location in zip(lines[::2], lines[1::2]):
+            if os.path.realpath(location) == os.path.realpath(root):
+                self._archive_name = archive
+        assert self._archive_name != None
+    def _get_archive_project_name(self, root):
+        # get project names
+        status,output,error = self._u_invoke_client("tree-version", directory=root)
+        # e.g output
+        # jdoe@example.com--bugs-everywhere-auto-2008.22.24.52/be--mainline--0.1
+        archive_name,project_name = output.rstrip('\n').split('/')
+        self._archive_name = archive_name
+        self._project_name = project_name
+    def _vcs_get_user_id(self):
+        try:
+            status,output,error = self._u_invoke_client('my-id')
+            return output.rstrip('\n')
+        except Exception, e:
+            if 'no arch user id set' in e.args[0]:
+                return None
+            else:
+                raise
+    def _vcs_set_user_id(self, value):
+        self._u_invoke_client('my-id', value)
+    def _vcs_add(self, path):
+        self._u_invoke_client("add-id", path)
+        realpath = os.path.realpath(self._u_abspath(path))
+        pathAdded = realpath in self._list_added(self.rootdir)
+        if self.paranoid and not pathAdded:
+            self._force_source(path)
+    def _list_added(self, root):
+        assert os.path.exists(root)
+        assert os.access(root, os.X_OK)
+        root = os.path.realpath(root)
+        status,output,error = self._u_invoke_client("inventory", "--source",
+                                                    "--both", "--all", root)
+        inv_str = output.rstrip('\n')
+        return [os.path.join(root, p) for p in inv_str.split('\n')]
+    def _add_dir_rule(self, rule, dirname, root):
+        inv_path = os.path.join(dirname, '.arch-inventory')
+        f = codecs.open(inv_path, "a", self.encoding)
+        f.write(rule)
+        f.close()
+        if os.path.realpath(inv_path) not in self._list_added(root):
+            paranoid = self.paranoid
+            self.paranoid = False
+            self.add(inv_path)
+            self.paranoid = paranoid
+    def _force_source(self, path):
+        rule = "source %s\n" % self._u_rel_path(path)
+        self._add_dir_rule(rule, os.path.dirname(path), self.rootdir)
+        if os.path.realpath(path) not in self._list_added(self.rootdir):
+            raise CantAddFile(path)
+    def _vcs_remove(self, path):
+        if not '.arch-ids' in path:
+            self._u_invoke_client("delete-id", path)
+    def _vcs_update(self, path):
+        pass
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
+        if revision == None:
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
+        else:
+            status,output,error = \
+                self._invoke_client("file-find", path, revision)
+            relpath = output.rstrip('\n')
+            abspath = os.path.join(self.rootdir, relpath)
+            f = codecs.open(abspath, "r", self.encoding)
+            contents = f.read()
+            f.close()
+            return contents
+    def _vcs_duplicate_repo(self, directory, revision=None):
+        if revision == None:
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+        else:
+            status,output,error = \
+                self._u_invoke_client("get", revision,directory)
+    def _vcs_commit(self, commitfile, allow_empty=False):
+        if allow_empty == False:
+            # arch applies empty commits without complaining, so check first
+            status,output,error = self._u_invoke_client("changes",expect=(0,1))
+            if status == 0:
+                raise vcs.EmptyCommit()
+        summary,body = self._u_parse_commitfile(commitfile)
+        args = ["commit", "--summary", summary]
+        if body != None:
+            args.extend(["--log-message",body])
+        status,output,error = self._u_invoke_client(*args)
+        revision = None
+        revline = re.compile("[*] committed (.*)")
+        match = revline.search(output)
+        assert match != None, output+error
+        assert len(match.groups()) == 1
+        revpath = match.groups()[0]
+        assert not " " in revpath, revpath
+        assert revpath.startswith(self._archive_project_name()+'--')
+        revision = revpath[len(self._archive_project_name()+'--'):]
+        return revpath
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("logs")
+        logs = output.splitlines()
+        first_log = logs.pop(0)
+        assert first_log == "base-0", first_log
+        try:
+            log = logs[index]
+        except IndexError:
+            return None
+        return "%s--%s" % (self._archive_project_name(), log)
+
+class CantAddFile(Exception):
+    def __init__(self, file):
+        self.file = file
+        Exception.__init__(self, "Can't automatically add file %s" % file)
+
+
+\f
+vcs.make_vcs_testcase_subclasses(Arch, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/beuuid.py b/interfaces/email/interactive/libbe/beuuid.py
new file mode 100644 (file)
index 0000000..490ed62
--- /dev/null
@@ -0,0 +1,63 @@
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Backwards compatibility support for Python 2.4.  Once people give up
+on 2.4 ;), the uuid call should be merged into bugdir.py
+"""
+
+import unittest
+
+
+try:
+    from uuid import uuid4 # Python >= 2.5
+    def uuid_gen():
+        id = uuid4()
+        idstr = id.urn
+        start = "urn:uuid:"
+        assert idstr.startswith(start)
+        return idstr[len(start):]
+except ImportError:
+    import os
+    import sys
+    from subprocess import Popen, PIPE
+
+    def uuid_gen():
+        # Shell-out to system uuidgen
+        args = ['uuidgen', 'r']
+        try:
+            if sys.platform != "win32":
+                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+            else:
+                # win32 don't have os.execvp() so have to run command in a shell
+                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, 
+                          shell=True, cwd=cwd)
+        except OSError, e :
+            strerror = "%s\nwhile executing %s" % (e.args[1], args)
+            raise OSError, strerror
+        output, error = q.communicate()
+        status = q.wait()
+        if status != 0:
+            strerror = "%s\nwhile executing %s" % (status, args)
+            raise Exception, strerror
+        return output.rstrip('\n')
+
+class UUIDtestCase(unittest.TestCase):
+    def testUUID_gen(self):
+        id = uuid_gen()
+        self.failUnless(len(id) == 36, "invalid UUID '%s'" % id)
+
+suite = unittest.TestLoader().loadTestsFromTestCase(UUIDtestCase)
diff --git a/interfaces/email/interactive/libbe/bug.py b/interfaces/email/interactive/libbe/bug.py
new file mode 100644 (file)
index 0000000..fd30ff7
--- /dev/null
@@ -0,0 +1,580 @@
+# Copyright (C) 2008-2009 Chris Ball <cjb@laptop.org>
+#                         Thomas Habets <thomas@habets.pp.se>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define the Bug class for representing bugs.
+"""
+
+import os
+import os.path
+import errno
+import time
+import types
+import xml.sax.saxutils
+import doctest
+
+from beuuid import uuid_gen
+from properties import Property, doc_property, local_property, \
+    defaulting_property, checked_property, cached_property, \
+    primed_property, change_hook_property, settings_property
+import settings_object
+import mapfile
+import comment
+import utility
+
+
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
+
+### Define and describe valid bug categories
+# Use a tuple of (category, description) tuples since we don't have
+# ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
+
+# in order of increasing severity.  (name, description) pairs
+severity_def = (
+  ("wishlist","A feature that could improve usefulness, but not a bug."),
+  ("minor","The standard bug level."),
+  ("serious","A bug that requires workarounds."),
+  ("critical","A bug that prevents some features from working at all."),
+  ("fatal","A bug that makes the package unusable."))
+
+# in order of increasing resolution
+# roughly following http://www.bugzilla.org/docs/3.2/en/html/lifecycle.html
+active_status_def = (
+  ("unconfirmed","A possible bug which lacks independent existance confirmation."),
+  ("open","A working bug that has not been assigned to a developer."),
+  ("assigned","A working bug that has been assigned to a developer."),
+  ("test","The code has been adjusted, but the fix is still being tested."))
+inactive_status_def = (
+  ("closed", "The bug is no longer relevant."),
+  ("fixed", "The bug should no longer occur."),
+  ("wontfix","It's not a bug, it's a feature."))
+
+
+### Convert the description tuples to more useful formats
+
+severity_values = ()
+severity_description = {}
+severity_index = {}
+def load_severities(severity_def):
+    global severity_values
+    global severity_description
+    global severity_index
+    if severity_def == None:
+        return
+    severity_values = tuple([val for val,description in severity_def])
+    severity_description = dict(severity_def)
+    severity_index = {}
+    for i,severity in enumerate(severity_values):
+        severity_index[severity] = i
+load_severities(severity_def)
+
+active_status_values = []
+inactive_status_values = []
+status_values = []
+status_description = {}
+status_index = {}
+def load_status(active_status_def, inactive_status_def):
+    global active_status_values
+    global inactive_status_values
+    global status_values
+    global status_description
+    global status_index
+    if active_status_def == None:
+        active_status_def = globals()["active_status_def"]
+    if inactive_status_def == None:
+        inactive_status_def = globals()["inactive_status_def"]
+    active_status_values = tuple([val for val,description in active_status_def])
+    inactive_status_values = tuple([val for val,description in inactive_status_def])
+    status_values = active_status_values + inactive_status_values
+    status_description = dict(tuple(active_status_def) + tuple(inactive_status_def))
+    status_index = {}
+    for i,status in enumerate(status_values):
+        status_index[status] = i
+load_status(active_status_def, inactive_status_def)
+
+
+class Bug(settings_object.SavedSettingsObject):
+    """
+    >>> b = Bug()
+    >>> print b.status
+    open
+    >>> print b.severity
+    minor
+
+    There are two formats for time, int and string.  Setting either
+    one will adjust the other appropriately.  The string form is the
+    one stored in the bug's settings file on disk.
+    >>> print type(b.time)
+    <type 'int'>
+    >>> print type(b.time_string)
+    <type 'str'>
+    >>> b.time = 0
+    >>> print b.time_string
+    Thu, 01 Jan 1970 00:00:00 +0000
+    >>> b.time_string="Thu, 01 Jan 1970 00:01:00 +0000"
+    >>> b.time
+    60
+    >>> print b.settings["time"]
+    Thu, 01 Jan 1970 00:01:00 +0000
+    """
+    settings_properties = []
+    required_saved_properties = []
+    _prop_save_settings = settings_object.prop_save_settings
+    _prop_load_settings = settings_object.prop_load_settings
+    def _versioned_property(settings_properties=settings_properties,
+                            required_saved_properties=required_saved_properties,
+                            **kwargs):
+        if "settings_properties" not in kwargs:
+            kwargs["settings_properties"] = settings_properties
+        if "required_saved_properties" not in kwargs:
+            kwargs["required_saved_properties"]=required_saved_properties
+        return settings_object.versioned_property(**kwargs)
+
+    @_versioned_property(name="severity",
+                         doc="A measure of the bug's importance",
+                         default="minor",
+                         check_fn=lambda s: s in severity_values,
+                         require_save=True)
+    def severity(): return {}
+
+    @_versioned_property(name="status",
+                         doc="The bug's current status",
+                         default="open",
+                         check_fn=lambda s: s in status_values,
+                         require_save=True)
+    def status(): return {}
+    
+    @property
+    def active(self):
+        return self.status in active_status_values
+
+    @_versioned_property(name="target",
+                         doc="The deadline for fixing this bug")
+    def target(): return {}
+
+    @_versioned_property(name="creator",
+                         doc="The user who entered the bug into the system")
+    def creator(): return {}
+
+    @_versioned_property(name="reporter",
+                         doc="The user who reported the bug")
+    def reporter(): return {}
+
+    @_versioned_property(name="assigned",
+                         doc="The developer in charge of the bug")
+    def assigned(): return {}
+
+    @_versioned_property(name="time",
+                         doc="An RFC 2822 timestamp for bug creation")
+    def time_string(): return {}
+
+    def _get_time(self):
+        if self.time_string == None:
+            return None
+        return utility.str_to_time(self.time_string)
+    def _set_time(self, value):
+        self.time_string = utility.time_to_str(value)
+    time = property(fget=_get_time,
+                    fset=_set_time,
+                    doc="An integer version of .time_string")
+
+    def _extra_strings_check_fn(value):
+        return utility.iterable_full_of_strings(value, \
+                         alternative=settings_object.EMPTY)
+    def _extra_strings_change_hook(self, old, new):
+        self.extra_strings.sort() # to make merging easier
+        self._prop_save_settings(old, new)
+    @_versioned_property(name="extra_strings",
+                         doc="Space for an array of extra strings.  Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
+                         default=[],
+                         check_fn=_extra_strings_check_fn,
+                         change_hook=_extra_strings_change_hook,
+                         mutable=True)
+    def extra_strings(): return {}
+
+    @_versioned_property(name="summary",
+                         doc="A one-line bug description")
+    def summary(): return {}
+
+    def _get_comment_root(self, load_full=False):
+        if self.sync_with_disk:
+            return comment.loadComments(self, load_full=load_full)
+        else:
+            return comment.Comment(self, uuid=comment.INVALID_UUID)
+
+    @Property
+    @cached_property(generator=_get_comment_root)
+    @local_property("comment_root")
+    @doc_property(doc="The trunk of the comment tree")
+    def comment_root(): return {}
+
+    def _get_vcs(self):
+        if hasattr(self.bugdir, "vcs"):
+            return self.bugdir.vcs
+
+    @Property
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
+    @doc_property(doc="A revision control system instance.")
+    def vcs(): return {}
+
+    def __init__(self, bugdir=None, uuid=None, from_disk=False,
+                 load_comments=False, summary=None):
+        settings_object.SavedSettingsObject.__init__(self)
+        self.bugdir = bugdir
+        self.uuid = uuid
+        if from_disk == True:
+            self.sync_with_disk = True
+        else:
+            self.sync_with_disk = False
+            if uuid == None:
+                self.uuid = uuid_gen()
+            self.time = int(time.time()) # only save to second precision
+            if self.vcs != None:
+                self.creator = self.vcs.get_user_id()
+            self.summary = summary
+
+    def __repr__(self):
+        return "Bug(uuid=%r)" % self.uuid
+
+    def __str__(self):
+        return self.string(shortlist=True)
+
+    def __cmp__(self, other):
+        return cmp_full(self, other)
+
+    # serializing methods
+
+    def _setting_attr_string(self, setting):
+        value = getattr(self, setting)
+        if value == None:
+            return ""
+        return str(value)
+
+    def xml(self, show_comments=False):
+        if self.bugdir == None:
+            shortname = self.uuid
+        else:
+            shortname = self.bugdir.bug_shortname(self)
+
+        if self.time == None:
+            timestring = ""
+        else:
+            timestring = utility.time_to_str(self.time)
+
+        info = [("uuid", self.uuid),
+                ("short-name", shortname),
+                ("severity", self.severity),
+                ("status", self.status),
+                ("assigned", self.assigned),
+                ("target", self.target),
+                ("reporter", self.reporter),
+                ("creator", self.creator),
+                ("created", timestring),
+                ("summary", self.summary)]
+        ret = '<bug>\n'
+        for (k,v) in info:
+            if v is not None:
+                ret += '  <%s>%s</%s>\n' % (k,xml.sax.saxutils.escape(v),k)
+        for estr in self.extra_strings:
+            ret += '  <extra-string>%s</extra-string>\n' % estr
+        if show_comments == True:
+            comout = self.comment_root.xml_thread(auto_name_map=True,
+                                                  bug_shortname=shortname)
+            if len(comout) > 0:
+                ret += comout+'\n'
+        ret += '</bug>'
+        return ret
+
+    def string(self, shortlist=False, show_comments=False):
+        if self.bugdir == None:
+            shortname = self.uuid
+        else:
+            shortname = self.bugdir.bug_shortname(self)
+        if shortlist == False:
+            if self.time == None:
+                timestring = ""
+            else:
+                htime = utility.handy_time(self.time)
+                timestring = "%s (%s)" % (htime, self.time_string)
+            info = [("ID", self.uuid),
+                    ("Short name", shortname),
+                    ("Severity", self.severity),
+                    ("Status", self.status),
+                    ("Assigned", self._setting_attr_string("assigned")),
+                    ("Target", self._setting_attr_string("target")),
+                    ("Reporter", self._setting_attr_string("reporter")),
+                    ("Creator", self._setting_attr_string("creator")),
+                    ("Created", timestring)]
+            longest_key_len = max([len(k) for k,v in info])
+            infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
+            bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
+        else:
+            statuschar = self.status[0]
+            severitychar = self.severity[0]
+            chars = "%c%c" % (statuschar, severitychar)
+            bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
+        
+        if show_comments == True:
+            # take advantage of the string_thread(auto_name_map=True)
+            # SIDE-EFFECT of sorting by comment time.
+            comout = self.comment_root.string_thread(flatten=False,
+                                                     auto_name_map=True,
+                                                     bug_shortname=shortname)
+            output = bugout + '\n' + comout.rstrip('\n')
+        else :
+            output = bugout
+        return output
+
+    # methods for saving/loading/acessing settings and properties.
+
+    def get_path(self, *args):
+        dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["values", "comments"], str(args)
+        return os.path.join(dir, *args)
+
+    def set_sync_with_disk(self, value):
+        self.sync_with_disk = value
+        for comment in self.comments():
+            comment.set_sync_with_disk(value)
+
+    def load_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load settings")
+        self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
+        self._setup_saved_settings()
+
+    def save_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("save settings")
+        assert self.summary != None, "Can't save blank bug"
+        self.vcs.mkdir(self.get_path())
+        path = self.get_path("values")
+        mapfile.map_save(self.vcs, path, self._get_saved_settings())
+
+    def save(self):
+        """
+        Save any loaded contents to disk.  Because of lazy loading of
+        comments, this is actually not too inefficient.
+        
+        However, if self.sync_with_disk = True, then any changes are
+        automatically written to disk as soon as they happen, so
+        calling this method will just waste time (unless something
+        else has been messing with your on-disk files).
+        """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
+        self.save_settings()
+        if len(self.comment_root) > 0:
+            comment.saveComments(self)
+        if sync_with_disk == False:
+            self.set_sync_with_disk(False)
+
+    def load_comments(self, load_full=True):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load comments")
+        if load_full == True:
+            # Force a complete load of the whole comment tree
+            self.comment_root = self._get_comment_root(load_full=True)
+        else:
+            # Setup for fresh lazy-loading.  Clear _comment_root, so
+            # _get_comment_root returns a fresh version.  Turn of
+            # syncing temporarily so we don't write our blank comment
+            # tree to disk.
+            self.sync_with_disk = False
+            self.comment_root = None
+            self.sync_with_disk = True
+
+    def remove(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("remove")
+        self.comment_root.remove()
+        path = self.get_path()
+        self.vcs.recursive_remove(path)
+    
+    # methods for managing comments
+
+    def comments(self):
+        for comment in self.comment_root.traverse():
+            yield comment
+
+    def new_comment(self, body=None):
+        comm = self.comment_root.new_reply(body=body)
+        return comm
+
+    def comment_from_shortname(self, shortname, *args, **kwargs):
+        return self.comment_root.comment_from_shortname(shortname,
+                                                        *args, **kwargs)
+
+    def comment_from_uuid(self, uuid):
+        return self.comment_root.comment_from_uuid(uuid)
+
+    def comment_shortnames(self, shortname=None):
+        """
+        SIDE-EFFECT : Comment.comment_shortnames will sort the comment
+        tree by comment.time
+        """
+        for id, comment in self.comment_root.comment_shortnames(shortname):
+            yield (id, comment)
+
+
+# The general rule for bug sorting is that "more important" bugs are
+# less than "less important" bugs.  This way sorting a list of bugs
+# will put the most important bugs first in the list.  When relative
+# importance is unclear, the sorting follows some arbitrary convention
+# (i.e. dictionary order).
+
+def cmp_severity(bug_1, bug_2):
+    """
+    Compare the severity levels of two bugs, with more severe bugs
+    comparing as less.
+    >>> bugA = Bug()
+    >>> bugB = Bug()
+    >>> bugA.severity = bugB.severity = "wishlist"
+    >>> cmp_severity(bugA, bugB) == 0
+    True
+    >>> bugB.severity = "minor"
+    >>> cmp_severity(bugA, bugB) > 0
+    True
+    >>> bugA.severity = "critical"
+    >>> cmp_severity(bugA, bugB) < 0
+    True
+    """
+    if not hasattr(bug_2, "severity") :
+        return 1
+    return -cmp(severity_index[bug_1.severity], severity_index[bug_2.severity])
+
+def cmp_status(bug_1, bug_2):
+    """
+    Compare the status levels of two bugs, with more 'open' bugs
+    comparing as less.
+    >>> bugA = Bug()
+    >>> bugB = Bug()
+    >>> bugA.status = bugB.status = "open"
+    >>> cmp_status(bugA, bugB) == 0
+    True
+    >>> bugB.status = "closed"
+    >>> cmp_status(bugA, bugB) < 0
+    True
+    >>> bugA.status = "fixed"
+    >>> cmp_status(bugA, bugB) > 0
+    True
+    """
+    if not hasattr(bug_2, "status") :
+        return 1
+    val_2 = status_index[bug_2.status]
+    return cmp(status_index[bug_1.status], status_index[bug_2.status])
+
+def cmp_attr(bug_1, bug_2, attr, invert=False):
+    """
+    Compare a general attribute between two bugs using the conventional
+    comparison rule for that attribute type.  If invert == True, sort
+    *against* that convention.
+    >>> attr="severity"
+    >>> bugA = Bug()
+    >>> bugB = Bug()
+    >>> bugA.severity = "critical"
+    >>> bugB.severity = "wishlist"
+    >>> cmp_attr(bugA, bugB, attr) < 0
+    True
+    >>> cmp_attr(bugA, bugB, attr, invert=True) > 0
+    True
+    >>> bugB.severity = "critical"
+    >>> cmp_attr(bugA, bugB, attr) == 0
+    True
+    """
+    if not hasattr(bug_2, attr) :
+        return 1
+    val_1 = getattr(bug_1, attr)
+    val_2 = getattr(bug_2, attr)
+    if val_1 == None: val_1 = None
+    if val_2 == None: val_2 = None
+    
+    if invert == True :
+        return -cmp(val_1, val_2)
+    else :
+        return cmp(val_1, val_2)
+
+# alphabetical rankings (a < z)
+cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
+cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
+cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
+cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
+cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
+cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
+# chronological rankings (newer < older)
+cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
+
+def cmp_comments(bug_1, bug_2):
+    """
+    Compare two bugs' comments lists.  Doesn't load any new comments,
+    so you should call each bug's .load_comments() first if you want a
+    full comparison.
+    """
+    comms_1 = sorted(bug_1.comments(), key = lambda comm : comm.uuid)
+    comms_2 = sorted(bug_2.comments(), key = lambda comm : comm.uuid)
+    result = cmp(len(comms_1), len(comms_2))
+    if result != 0:
+        return result
+    for c_1,c_2 in zip(comms_1, comms_2):
+        result = cmp(c_1, c_2)
+        if result != 0:
+            return result
+    return 0
+
+DEFAULT_CMP_FULL_CMP_LIST = \
+    (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
+     cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid)
+
+class BugCompoundComparator (object):
+    def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
+        self.cmp_list = cmp_list
+    def __call__(self, bug_1, bug_2):
+        for comparison in self.cmp_list :
+            val = comparison(bug_1, bug_2)
+            if val != 0 :
+                return val
+        return 0
+        
+cmp_full = BugCompoundComparator()
+
+
+# define some bonus cmp_* functions
+def cmp_last_modified(bug_1, bug_2):
+    """
+    Like cmp_time(), but use most recent comment instead of bug
+    creation for the timestamp.
+    """
+    def last_modified(bug):
+        time = bug.time
+        for comment in bug.comment_root.traverse():
+            if comment.time > time:
+                time = comment.time
+        return time
+    val_1 = last_modified(bug_1)
+    val_2 = last_modified(bug_2)
+    return -cmp(val_1, val_2)
+
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/bugdir.py b/interfaces/email/interactive/libbe/bugdir.py
new file mode 100644 (file)
index 0000000..c4f0f91
--- /dev/null
@@ -0,0 +1,832 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Alexander Belchenko <bialix@ukr.net>
+#                         Chris Ball <cjb@laptop.org>
+#                         Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define the BugDir class for representing bug comments.
+"""
+
+import copy
+import errno
+import os
+import os.path
+import sys
+import time
+import unittest
+import doctest
+
+import bug
+import encoding
+from properties import Property, doc_property, local_property, \
+    defaulting_property, checked_property, fn_checked_property, \
+    cached_property, primed_property, change_hook_property, \
+    settings_property
+import mapfile
+import vcs
+import settings_object
+import upgrade
+import utility
+
+
+class NoBugDir(Exception):
+    def __init__(self, path):
+        msg = "The directory \"%s\" has no bug directory." % path
+        Exception.__init__(self, msg)
+        self.path = path
+
+class NoRootEntry(Exception):
+    def __init__(self, path):
+        self.path = path
+        Exception.__init__(self, "Specified root does not exist: %s" % path)
+
+class AlreadyInitialized(Exception):
+    def __init__(self, path):
+        self.path = path
+        Exception.__init__(self,
+                           "Specified root is already initialized: %s" % path)
+
+class MultipleBugMatches(ValueError):
+    def __init__(self, shortname, matches):
+        msg = ("More than one bug matches %s.  "
+               "Please be more specific.\n%s" % (shortname, matches))
+        ValueError.__init__(self, msg)
+        self.shortname = shortname
+        self.matches = matches
+
+class NoBugMatches(KeyError):
+    def __init__(self, shortname):
+        msg = "No bug matches %s" % shortname
+        KeyError.__init__(self, msg)
+        self.shortname = shortname
+
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
+
+
+class BugDir (list, settings_object.SavedSettingsObject):
+    """
+    Sink to existing root
+    ======================
+
+    Consider the following usage case:
+    You have a bug directory rooted in
+      /path/to/source
+    by which I mean the '.be' directory is at
+      /path/to/source/.be
+    However, you're of in some subdirectory like
+      /path/to/source/GUI/testing
+    and you want to comment on a bug.  Setting sink_to_root=True wen
+    you initialize your BugDir will cause it to search for the '.be'
+    file in the ancestors of the path you passed in as 'root'.
+      /path/to/source/GUI/testing/.be     miss
+      /path/to/source/GUI/.be             miss
+      /path/to/source/.be                 hit!
+    So it still roots itself appropriately without much work for you.
+
+    File-system access
+    ==================
+
+    BugDirs live completely in memory when .sync_with_disk is False.
+    This is the default configuration setup by BugDir(from_disk=False).
+    If .sync_with_disk == True (e.g. BugDir(from_disk=True)), then
+    any changes to the BugDir will be immediately written to disk.
+
+    If you want to change .sync_with_disk, we suggest you use
+    .set_sync_with_disk(), which propogates the new setting through to
+    all bugs/comments/etc. that have been loaded into memory.  If
+    you've been living in memory and want to move to
+    .sync_with_disk==True, but you're not sure if anything has been
+    changed in memory, a call to save() immediately before the
+    .set_sync_with_disk(True) call is a safe move.
+
+    Regardless of .sync_with_disk, a call to .save() will write out
+    all the contents that the BugDir instance has loaded into memory.
+    If sync_with_disk has been True over the course of all interesting
+    changes, this .save() call will be a waste of time.
+
+    The BugDir will only load information from the file system when it
+    loads new settings/bugs/comments that it doesn't already have in
+    memory and .sync_with_disk == True.
+
+    Allow VCS initialization
+    ========================
+
+    This one is for testing purposes.  Setting it to True allows the
+    BugDir to search for an installed VCS backend and initialize it in
+    the root directory.  This is a convenience option for supporting
+    tests of versioning functionality (e.g. .duplicate_bugdir).
+
+    Disable encoding manipulation
+    =============================
+
+    This one is for testing purposed.  You might have non-ASCII
+    Unicode in your bugs, comments, files, etc.  BugDir instances try
+    and support your preferred encoding scheme (e.g. "utf-8") when
+    dealing with stream and file input/output.  For stream output,
+    this involves replacing sys.stdout and sys.stderr
+    (libbe.encode.set_IO_stream_encodings).  However this messes up
+    doctest's output catching.  In order to support doctest tests
+    using BugDirs, set manipulate_encodings=False, and stick to ASCII
+    in your tests.
+    """
+
+    settings_properties = []
+    required_saved_properties = []
+    _prop_save_settings = settings_object.prop_save_settings
+    _prop_load_settings = settings_object.prop_load_settings
+    def _versioned_property(settings_properties=settings_properties,
+                            required_saved_properties=required_saved_properties,
+                            **kwargs):
+        if "settings_properties" not in kwargs:
+            kwargs["settings_properties"] = settings_properties
+        if "required_saved_properties" not in kwargs:
+            kwargs["required_saved_properties"]=required_saved_properties
+        return settings_object.versioned_property(**kwargs)
+
+    @_versioned_property(name="target",
+                         doc="The current project development target.")
+    def target(): return {}
+
+    def _guess_encoding(self):
+        return encoding.get_encoding()
+    def _check_encoding(value):
+        if value != None:
+            return encoding.known_encoding(value)
+    def _setup_encoding(self, new_encoding):
+        # change hook called before generator.
+        if new_encoding not in [None, settings_object.EMPTY]:
+            if self._manipulate_encodings == True:
+                encoding.set_IO_stream_encodings(new_encoding)
+    def _set_encoding(self, old_encoding, new_encoding):
+        self._setup_encoding(new_encoding)
+        self._prop_save_settings(old_encoding, new_encoding)
+
+    @_versioned_property(name="encoding",
+                         doc="""The default input/output encoding to use (e.g. "utf-8").""",
+                         change_hook=_set_encoding,
+                         generator=_guess_encoding,
+                         check_fn=_check_encoding)
+    def encoding(): return {}
+
+    def _setup_user_id(self, user_id):
+        self.vcs.user_id = user_id
+    def _guess_user_id(self):
+        return self.vcs.get_user_id()
+    def _set_user_id(self, old_user_id, new_user_id):
+        self._setup_user_id(new_user_id)
+        self._prop_save_settings(old_user_id, new_user_id)
+
+    @_versioned_property(name="user_id",
+                         doc=
+"""The user's prefered name, e.g. 'John Doe <jdoe@example.com>'.  Note
+that the Arch VCS backend *enforces* ids with this format.""",
+                         change_hook=_set_user_id,
+                         generator=_guess_user_id)
+    def user_id(): return {}
+
+    @_versioned_property(name="default_assignee",
+                         doc=
+"""The default assignee for new bugs e.g. 'John Doe <jdoe@example.com>'.""")
+    def default_assignee(): return {}
+
+    @_versioned_property(name="vcs_name",
+                         doc="""The name of the current VCS.  Kept seperate to make saving/loading
+settings easy.  Don't set this attribute.  Set .vcs instead, and
+.vcs_name will be automatically adjusted.""",
+                         default="None",
+                         allowed=["None", "Arch", "bzr", "darcs", "git", "hg"])
+    def vcs_name(): return {}
+
+    def _get_vcs(self, vcs_name=None):
+        """Get and root a new revision control system"""
+        if vcs_name == None:
+            vcs_name = self.vcs_name
+        new_vcs = vcs.vcs_by_name(vcs_name)
+        self._change_vcs(None, new_vcs)
+        return new_vcs
+    def _change_vcs(self, old_vcs, new_vcs):
+        new_vcs.encoding = self.encoding
+        new_vcs.root(self.root)
+        self.vcs_name = new_vcs.name
+
+    @Property
+    @change_hook_property(hook=_change_vcs)
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
+    @doc_property(doc="A revision control system instance.")
+    def vcs(): return {}
+
+    def _bug_map_gen(self):
+        map = {}
+        for bug in self:
+            map[bug.uuid] = bug
+        for uuid in self.list_uuids():
+            if uuid not in map:
+                map[uuid] = None
+        self._bug_map_value = map # ._bug_map_value used by @local_property
+
+    def _extra_strings_check_fn(value):
+        return utility.iterable_full_of_strings(value, \
+                         alternative=settings_object.EMPTY)
+    def _extra_strings_change_hook(self, old, new):
+        self.extra_strings.sort() # to make merging easier
+        self._prop_save_settings(old, new)
+    @_versioned_property(name="extra_strings",
+                         doc="Space for an array of extra strings.  Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
+                         default=[],
+                         check_fn=_extra_strings_check_fn,
+                         change_hook=_extra_strings_change_hook,
+                         mutable=True)
+    def extra_strings(): return {}
+
+    @Property
+    @primed_property(primer=_bug_map_gen)
+    @local_property("bug_map")
+    @doc_property(doc="A dict of (bug-uuid, bug-instance) pairs.")
+    def _bug_map(): return {}
+
+    def _setup_severities(self, severities):
+        if severities not in [None, settings_object.EMPTY]:
+            bug.load_severities(severities)
+    def _set_severities(self, old_severities, new_severities):
+        self._setup_severities(new_severities)
+        self._prop_save_settings(old_severities, new_severities)
+    @_versioned_property(name="severities",
+                         doc="The allowed bug severities and their descriptions.",
+                         change_hook=_set_severities)
+    def severities(): return {}
+
+    def _setup_status(self, active_status, inactive_status):
+        bug.load_status(active_status, inactive_status)
+    def _set_active_status(self, old_active_status, new_active_status):
+        self._setup_status(new_active_status, self.inactive_status)
+        self._prop_save_settings(old_active_status, new_active_status)
+    @_versioned_property(name="active_status",
+                         doc="The allowed active bug states and their descriptions.",
+                         change_hook=_set_active_status)
+    def active_status(): return {}
+
+    def _set_inactive_status(self, old_inactive_status, new_inactive_status):
+        self._setup_status(self.active_status, new_inactive_status)
+        self._prop_save_settings(old_inactive_status, new_inactive_status)
+    @_versioned_property(name="inactive_status",
+                         doc="The allowed inactive bug states and their descriptions.",
+                         change_hook=_set_inactive_status)
+    def inactive_status(): return {}
+
+
+    def __init__(self, root=None, sink_to_existing_root=True,
+                 assert_new_BugDir=False, allow_vcs_init=False,
+                 manipulate_encodings=True, from_disk=False, vcs=None):
+        list.__init__(self)
+        settings_object.SavedSettingsObject.__init__(self)
+        self._manipulate_encodings = manipulate_encodings
+        if root == None:
+            root = os.getcwd()
+        if sink_to_existing_root == True:
+            self.root = self._find_root(root)
+        else:
+            if not os.path.exists(root):
+                raise NoRootEntry(root)
+            self.root = root
+        # get a temporary vcs until we've loaded settings
+        self.sync_with_disk = False
+        self.vcs = self._guess_vcs()
+
+        if from_disk == True:
+            self.sync_with_disk = True
+            self.load()
+        else:
+            self.sync_with_disk = False
+            if assert_new_BugDir == True:
+                if os.path.exists(self.get_path()):
+                    raise AlreadyInitialized, self.get_path()
+            if vcs == None:
+                vcs = self._guess_vcs(allow_vcs_init)
+            self.vcs = vcs
+            self._setup_user_id(self.user_id)
+
+    def __del__(self):
+        self.cleanup()
+
+    def cleanup(self):
+        self.vcs.cleanup()
+
+    # methods for getting the BugDir situated in the filesystem
+
+    def _find_root(self, path):
+        """
+        Search for an existing bug database dir and it's ancestors and
+        return a BugDir rooted there.  Only called by __init__, and
+        then only if sink_to_existing_root == True.
+        """
+        if not os.path.exists(path):
+            raise NoRootEntry(path)
+        versionfile=utility.search_parent_directories(path,
+                                                      os.path.join(".be", "version"))
+        if versionfile != None:
+            beroot = os.path.dirname(versionfile)
+            root = os.path.dirname(beroot)
+            return root
+        else:
+            beroot = utility.search_parent_directories(path, ".be")
+            if beroot == None:
+                raise NoBugDir(path)
+            return beroot
+
+    def _guess_vcs(self, allow_vcs_init=False):
+        """
+        Only called by __init__.
+        """
+        deepdir = self.get_path()
+        if not os.path.exists(deepdir):
+            deepdir = os.path.dirname(deepdir)
+        new_vcs = vcs.detect_vcs(deepdir)
+        install = False
+        if new_vcs.name == "None":
+            if allow_vcs_init == True:
+                new_vcs = vcs.installed_vcs()
+                new_vcs.init(self.root)
+        return new_vcs
+
+    # methods for saving/loading/accessing settings and properties.
+
+    def get_path(self, *args):
+        """
+        Return a path relative to .root.
+        """
+        dir = os.path.join(self.root, ".be")
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["version", "settings", "bugs"], str(args)
+        return os.path.join(dir, *args)
+
+    def _get_settings(self, settings_path, for_duplicate_bugdir=False):
+        allow_no_vcs = not self.vcs.path_in_root(settings_path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        if self.sync_with_disk == False and for_duplicate_bugdir == False:
+            # duplicates can ignore this bugdir's .sync_with_disk status
+            raise DiskAccessRequired("_get settings")
+        try:
+            settings = mapfile.map_load(self.vcs, settings_path, allow_no_vcs)
+        except vcs.NoSuchFile:
+            settings = {"vcs_name": "None"}
+        return settings
+
+    def _save_settings(self, settings_path, settings,
+                       for_duplicate_bugdir=False):
+        allow_no_vcs = not self.vcs.path_in_root(settings_path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        if self.sync_with_disk == False and for_duplicate_bugdir == False:
+            # duplicates can ignore this bugdir's .sync_with_disk status
+            raise DiskAccessRequired("_save settings")
+        self.vcs.mkdir(self.get_path(), allow_no_vcs)
+        mapfile.map_save(self.vcs, settings_path, settings, allow_no_vcs)
+
+    def load_settings(self):
+        self.settings = self._get_settings(self.get_path("settings"))
+        self._setup_saved_settings()
+        self._setup_user_id(self.user_id)
+        self._setup_encoding(self.encoding)
+        self._setup_severities(self.severities)
+        self._setup_status(self.active_status, self.inactive_status)
+        self.vcs = vcs.vcs_by_name(self.vcs_name)
+        self._setup_user_id(self.user_id)
+
+    def save_settings(self):
+        settings = self._get_saved_settings()
+        self._save_settings(self.get_path("settings"), settings)
+
+    def get_version(self, path=None, use_none_vcs=False,
+                    for_duplicate_bugdir=False):
+        """
+        Requires disk access.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("get version")
+        if use_none_vcs == True:
+            VCS = vcs.vcs_by_name("None")
+            VCS.root(self.root)
+            VCS.encoding = encoding.get_encoding()
+        else:
+            VCS = self.vcs
+
+        if path == None:
+            path = self.get_path("version")
+        allow_no_vcs = not VCS.path_in_root(path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        version = VCS.get_file_contents(
+            path, allow_no_vcs=allow_no_vcs).rstrip("\n")
+        return version
+
+    def set_version(self):
+        """
+        Requires disk access.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("set version")
+        self.vcs.mkdir(self.get_path())
+        self.vcs.set_file_contents(self.get_path("version"),
+                                   upgrade.BUGDIR_DISK_VERSION+"\n")
+
+    # methods controlling disk access
+
+    def set_sync_with_disk(self, value):
+        """
+        Adjust .sync_with_disk for the BugDir and all it's children.
+        See the BugDir docstring for a description of the role of
+        .sync_with_disk.
+        """
+        self.sync_with_disk = value
+        for bug in self:
+            bug.set_sync_with_disk(value)
+
+    def load(self):
+        """
+        Reqires disk access
+        """
+        version = self.get_version(use_none_vcs=True)
+        if version != upgrade.BUGDIR_DISK_VERSION:
+            upgrade.upgrade(self.root, version)
+        else:
+            if not os.path.exists(self.get_path()):
+                raise NoBugDir(self.get_path())
+            self.load_settings()
+
+    def load_all_bugs(self):
+        """
+        Requires disk access.
+        Warning: this could take a while.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load all bugs")
+        self._clear_bugs()
+        for uuid in self.list_uuids():
+            self._load_bug(uuid)
+
+    def save(self):
+        """
+        Note that this command writes to disk _regardless_ of the
+        status of .sync_with_disk.
+
+        Save any loaded contents to disk.  Because of lazy loading of
+        bugs and comments, this is actually not too inefficient.
+
+        However, if .sync_with_disk = True, then any changes are
+        automatically written to disk as soon as they happen, so
+        calling this method will just waste time (unless something
+        else has been messing with your on-disk files).
+
+        Requires disk access.
+        """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
+        self.set_version()
+        self.save_settings()
+        for bug in self:
+            bug.save()
+        if sync_with_disk == False:
+            self.set_sync_with_disk(sync_with_disk)
+
+    # methods for managing duplicate BugDirs
+
+    def duplicate_bugdir(self, revision):
+        duplicate_path = self.vcs.duplicate_repo(revision)
+
+        duplicate_version_path = os.path.join(duplicate_path, ".be", "version")
+        try:
+            version = self.get_version(duplicate_version_path,
+                                       for_duplicate_bugdir=True)
+        except DiskAccessRequired:
+            self.sync_with_disk = True # temporarily allow access
+            version = self.get_version(duplicate_version_path,
+                                       for_duplicate_bugdir=True)
+            self.sync_with_disk = False
+        if version != upgrade.BUGDIR_DISK_VERSION:
+            upgrade.upgrade(duplicate_path, version)
+
+        # setup revision VCS as None, since the duplicate may not be
+        # initialized for versioning
+        duplicate_settings_path = os.path.join(duplicate_path,
+                                               ".be", "settings")
+        duplicate_settings = self._get_settings(duplicate_settings_path,
+                                                for_duplicate_bugdir=True)
+        if "vcs_name" in duplicate_settings:
+            duplicate_settings["vcs_name"] = "None"
+            duplicate_settings["user_id"] = self.user_id
+        if "disabled" in bug.status_values:
+            # Hack to support old versions of BE bugs
+            duplicate_settings["inactive_status"] = self.inactive_status
+        self._save_settings(duplicate_settings_path, duplicate_settings,
+                            for_duplicate_bugdir=True)
+
+        return BugDir(duplicate_path, from_disk=True, manipulate_encodings=self._manipulate_encodings)
+
+    def remove_duplicate_bugdir(self):
+        self.vcs.remove_duplicate_repo()
+
+    # methods for managing bugs
+
+    def list_uuids(self):
+        uuids = []
+        if self.sync_with_disk == True and os.path.exists(self.get_path()):
+            # list the uuids on disk
+            if os.path.exists(self.get_path("bugs")):
+                for uuid in os.listdir(self.get_path("bugs")):
+                    if not (uuid.startswith('.')):
+                        uuids.append(uuid)
+                        yield uuid
+        # and the ones that are still just in memory
+        for bug in self:
+            if bug.uuid not in uuids:
+                uuids.append(bug.uuid)
+                yield bug.uuid
+
+    def _clear_bugs(self):
+        while len(self) > 0:
+            self.pop()
+        self._bug_map_gen()
+
+    def _load_bug(self, uuid):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("_load bug")
+        bg = bug.Bug(bugdir=self, uuid=uuid, from_disk=True)
+        self.append(bg)
+        self._bug_map_gen()
+        return bg
+
+    def new_bug(self, uuid=None, summary=None):
+        bg = bug.Bug(bugdir=self, uuid=uuid, summary=summary)
+        bg.set_sync_with_disk(self.sync_with_disk)
+        if bg.sync_with_disk == True:
+            bg.save()
+        self.append(bg)
+        self._bug_map_gen()
+        return bg
+
+    def remove_bug(self, bug):
+        self.remove(bug)
+        if bug.sync_with_disk == True:
+            bug.remove()
+
+    def bug_shortname(self, bug):
+        """
+        Generate short names from uuids.  Picks the minimum number of
+        characters (>=3) from the beginning of the uuid such that the
+        short names are unique.
+
+        Obviously, as the number of bugs in the database grows, these
+        short names will cease to be unique.  The complete uuid should be
+        used for long term reference.
+        """
+        chars = 3
+        for uuid in self._bug_map.keys():
+            if bug.uuid == uuid:
+                continue
+            while (bug.uuid[:chars] == uuid[:chars]):
+                chars+=1
+        return bug.uuid[:chars]
+
+    def bug_from_shortname(self, shortname):
+        """
+        >>> bd = SimpleBugDir(sync_with_disk=False)
+        >>> bug_a = bd.bug_from_shortname('a')
+        >>> print type(bug_a)
+        <class 'libbe.bug.Bug'>
+        >>> print bug_a
+        a:om: Bug A
+        >>> bd.cleanup()
+        """
+        matches = []
+        self._bug_map_gen()
+        for uuid in self._bug_map.keys():
+            if uuid.startswith(shortname):
+                matches.append(uuid)
+        if len(matches) > 1:
+            raise MultipleBugMatches(shortname, matches)
+        if len(matches) == 1:
+            return self.bug_from_uuid(matches[0])
+        raise NoBugMatches(shortname)
+
+    def bug_from_uuid(self, uuid):
+        if not self.has_bug(uuid):
+            raise KeyError("No bug matches %s\n  bug map: %s\n  root: %s" \
+                               % (uuid, self._bug_map, self.root))
+        if self._bug_map[uuid] == None:
+            self._load_bug(uuid)
+        return self._bug_map[uuid]
+
+    def has_bug(self, bug_uuid):
+        if bug_uuid not in self._bug_map:
+            self._bug_map_gen()
+            if bug_uuid not in self._bug_map:
+                return False
+        return True
+
+
+class SimpleBugDir (BugDir):
+    """
+    For testing.  Set sync_with_disk==False for a memory-only bugdir.
+    >>> bugdir = SimpleBugDir()
+    >>> uuids = list(bugdir.list_uuids())
+    >>> uuids.sort()
+    >>> print uuids
+    ['a', 'b']
+    >>> bugdir.cleanup()
+    """
+    def __init__(self, sync_with_disk=True):
+        if sync_with_disk == True:
+            dir = utility.Dir()
+            assert os.path.exists(dir.path)
+            root = dir.path
+            assert_new_BugDir = True
+            vcs_init = True
+        else:
+            root = "/"
+            assert_new_BugDir = False
+            vcs_init = False
+        BugDir.__init__(self, root, sink_to_existing_root=False,
+                    assert_new_BugDir=assert_new_BugDir,
+                    allow_vcs_init=vcs_init,
+                    manipulate_encodings=False)
+        if sync_with_disk == True: # postpone cleanup since dir.__del__() removes dir.
+            self._dir_ref = dir
+        bug_a = self.new_bug("a", summary="Bug A")
+        bug_a.creator = "John Doe <jdoe@example.com>"
+        bug_a.time = 0
+        bug_b = self.new_bug("b", summary="Bug B")
+        bug_b.creator = "Jane Doe <jdoe@example.com>"
+        bug_b.time = 0
+        bug_b.status = "closed"
+        if sync_with_disk == True:
+            self.save()
+            self.set_sync_with_disk(True)
+    def cleanup(self):
+        if hasattr(self, "_dir_ref"):
+            self._dir_ref.cleanup()
+        BugDir.cleanup(self)
+
+class BugDirTestCase(unittest.TestCase):
+    def setUp(self):
+        self.dir = utility.Dir()
+        self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
+                             allow_vcs_init=True)
+        self.vcs = self.bugdir.vcs
+    def tearDown(self):
+        self.bugdir.cleanup()
+        self.dir.cleanup()
+    def fullPath(self, path):
+        return os.path.join(self.dir.path, path)
+    def assertPathExists(self, path):
+        fullpath = self.fullPath(path)
+        self.failUnless(os.path.exists(fullpath)==True,
+                        "path %s does not exist" % fullpath)
+        self.assertRaises(AlreadyInitialized, BugDir,
+                          self.dir.path, assertNewBugDir=True)
+    def versionTest(self):
+        if self.vcs.versioned == False:
+            return
+        original = self.bugdir.vcs.commit("Began versioning")
+        bugA = self.bugdir.bug_from_uuid("a")
+        bugA.status = "fixed"
+        self.bugdir.save()
+        new = self.vcs.commit("Fixed bug a")
+        dupdir = self.bugdir.duplicate_bugdir(original)
+        self.failUnless(dupdir.root != self.bugdir.root,
+                        "%s, %s" % (dupdir.root, self.bugdir.root))
+        bugAorig = dupdir.bug_from_uuid("a")
+        self.failUnless(bugA != bugAorig,
+                        "\n%s\n%s" % (bugA.string(), bugAorig.string()))
+        bugAorig.status = "fixed"
+        self.failUnless(bug.cmp_status(bugA, bugAorig)==0,
+                        "%s, %s" % (bugA.status, bugAorig.status))
+        self.failUnless(bug.cmp_severity(bugA, bugAorig)==0,
+                        "%s, %s" % (bugA.severity, bugAorig.severity))
+        self.failUnless(bug.cmp_assigned(bugA, bugAorig)==0,
+                        "%s, %s" % (bugA.assigned, bugAorig.assigned))
+        self.failUnless(bug.cmp_time(bugA, bugAorig)==0,
+                        "%s, %s" % (bugA.time, bugAorig.time))
+        self.failUnless(bug.cmp_creator(bugA, bugAorig)==0,
+                        "%s, %s" % (bugA.creator, bugAorig.creator))
+        self.failUnless(bugA == bugAorig,
+                        "\n%s\n%s" % (bugA.string(), bugAorig.string()))
+        self.bugdir.remove_duplicate_bugdir()
+        self.failUnless(os.path.exists(dupdir.root)==False, str(dupdir.root))
+    def testRun(self):
+        self.bugdir.new_bug(uuid="a", summary="Ant")
+        self.bugdir.new_bug(uuid="b", summary="Cockroach")
+        self.bugdir.new_bug(uuid="c", summary="Praying mantis")
+        length = len(self.bugdir)
+        self.failUnless(length == 3, "%d != 3 bugs" % length)
+        uuids = list(self.bugdir.list_uuids())
+        self.failUnless(len(uuids) == 3, "%d != 3 uuids" % len(uuids))
+        self.failUnless(uuids == ["a","b","c"], str(uuids))
+        bugA = self.bugdir.bug_from_uuid("a")
+        bugAprime = self.bugdir.bug_from_shortname("a")
+        self.failUnless(bugA == bugAprime, "%s != %s" % (bugA, bugAprime))
+        self.bugdir.save()
+        self.versionTest()
+    def testComments(self, sync_with_disk=False):
+        if sync_with_disk == True:
+            self.bugdir.set_sync_with_disk(True)
+        self.bugdir.new_bug(uuid="a", summary="Ant")
+        bug = self.bugdir.bug_from_uuid("a")
+        comm = bug.comment_root
+        rep = comm.new_reply("Ants are small.")
+        rep.new_reply("And they have six legs.")
+        if sync_with_disk == False:
+            self.bugdir.save()
+            self.bugdir.set_sync_with_disk(True)
+        self.bugdir._clear_bugs()
+        bug = self.bugdir.bug_from_uuid("a")
+        bug.load_comments()
+        if sync_with_disk == False:
+            self.bugdir.set_sync_with_disk(False)
+        self.failUnless(len(bug.comment_root)==1, len(bug.comment_root))
+        for index,comment in enumerate(bug.comments()):
+            if index == 0:
+                repLoaded = comment
+                self.failUnless(repLoaded.uuid == rep.uuid, repLoaded.uuid)
+                self.failUnless(comment.sync_with_disk == sync_with_disk,
+                                comment.sync_with_disk)
+                self.failUnless(comment.content_type == "text/plain",
+                                comment.content_type)
+                self.failUnless(repLoaded.settings["Content-type"]=="text/plain",
+                                repLoaded.settings)
+                self.failUnless(repLoaded.body == "Ants are small.",
+                                repLoaded.body)
+            elif index == 1:
+                self.failUnless(comment.in_reply_to == repLoaded.uuid,
+                                repLoaded.uuid)
+                self.failUnless(comment.body == "And they have six legs.",
+                                comment.body)
+            else:
+                self.failIf(True, "Invalid comment: %d\n%s" % (index, comment))
+    def testSyncedComments(self):
+        self.testComments(sync_with_disk=True)
+
+class SimpleBugDirTestCase (unittest.TestCase):
+    def setUp(self):
+        # create a pre-existing bugdir in a temporary directory
+        self.dir = utility.Dir()
+        self.original_working_dir = os.getcwd()
+        os.chdir(self.dir.path)
+        self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
+                             allow_vcs_init=True)
+        self.bugdir.new_bug("preexisting", summary="Hopefully not imported")
+        self.bugdir.save()
+    def tearDown(self):
+        os.chdir(self.original_working_dir)
+        self.bugdir.cleanup()
+        self.dir.cleanup()
+    def testOnDiskCleanLoad(self):
+        """SimpleBugDir(sync_with_disk==True) should not import preexisting bugs."""
+        bugdir = SimpleBugDir(sync_with_disk=True)
+        self.failUnless(bugdir.sync_with_disk==True, bugdir.sync_with_disk)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir._clear_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == [], uuids)
+        bugdir.load_all_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir.cleanup()
+    def testInMemoryCleanLoad(self):
+        """SimpleBugDir(sync_with_disk==False) should not import preexisting bugs."""
+        bugdir = SimpleBugDir(sync_with_disk=False)
+        self.failUnless(bugdir.sync_with_disk==False, bugdir.sync_with_disk)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        self.failUnlessRaises(DiskAccessRequired, bugdir.load_all_bugs)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir._clear_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == [], uuids)
+        bugdir.cleanup()
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/bzr.py b/interfaces/email/interactive/libbe/bzr.py
new file mode 100644 (file)
index 0000000..e9e0649
--- /dev/null
@@ -0,0 +1,113 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Ben Finney <ben+python@benfinney.id.au>
+#                         Marien Zwart <marienz@gentoo.org>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Bazaar (bzr) backend.
+"""
+
+import os
+import re
+import sys
+import unittest
+import doctest
+
+import vcs
+
+
+def new():
+    return Bzr()
+
+class Bzr(vcs.VCS):
+    name = "bzr"
+    client = "bzr"
+    versioned = True
+    def _vcs_help(self):
+        status,output,error = self._u_invoke_client("--help")
+        return output        
+    def _vcs_detect(self, path):
+        if self._u_search_parent_directories(path, ".bzr") != None :
+            return True
+        return False
+    def _vcs_root(self, path):
+        """Find the root of the deepest repository containing path."""
+        status,output,error = self._u_invoke_client("root", path)
+        return output.rstrip('\n')
+    def _vcs_init(self, path):
+        self._u_invoke_client("init", directory=path)
+    def _vcs_get_user_id(self):
+        status,output,error = self._u_invoke_client("whoami")
+        return output.rstrip('\n')
+    def _vcs_set_user_id(self, value):
+        self._u_invoke_client("whoami", value)
+    def _vcs_add(self, path):
+        self._u_invoke_client("add", path)
+    def _vcs_remove(self, path):
+        # --force to also remove unversioned files.
+        self._u_invoke_client("remove", "--force", path)
+    def _vcs_update(self, path):
+        pass
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
+        if revision == None:
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
+        else:
+            status,output,error = \
+                self._u_invoke_client("cat","-r",revision,path)
+            return output
+    def _vcs_duplicate_repo(self, directory, revision=None):
+        if revision == None:
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+        else:
+            self._u_invoke_client("branch", "--revision", revision,
+                                  ".", directory)
+    def _vcs_commit(self, commitfile, allow_empty=False):
+        args = ["commit", "--file", commitfile]
+        if allow_empty == True:
+            args.append("--unchanged")
+            status,output,error = self._u_invoke_client(*args)
+        else:
+            kwargs = {"expect":(0,3)}
+            status,output,error = self._u_invoke_client(*args, **kwargs)
+            if status != 0:
+                strings = ["ERROR: no changes to commit.", # bzr 1.3.1
+                           "ERROR: No changes to commit."] # bzr 1.15.1
+                if self._u_any_in_string(strings, error) == True:
+                    raise vcs.EmptyCommit()
+                else:
+                    raise vcs.CommandError(args, status, stdout="", stderr=error)
+        revision = None
+        revline = re.compile("Committed revision (.*)[.]")
+        match = revline.search(error)
+        assert match != None, output+error
+        assert len(match.groups()) == 1
+        revision = match.groups()[0]
+        return revision
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("revno")
+        current_revision = int(output)
+        if index >= current_revision or index < -current_revision:
+            return None
+        if index >= 0:
+            return str(index+1) # bzr commit 0 is the empty tree.
+        return str(current_revision+index+1)
+
+\f    
+vcs.make_vcs_testcase_subclasses(Bzr, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/cmdutil.py b/interfaces/email/interactive/libbe/cmdutil.py
new file mode 100644 (file)
index 0000000..9b64142
--- /dev/null
@@ -0,0 +1,233 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define assorted utilities to make command-line handling easier.
+"""
+
+import glob
+import optparse
+import os
+from textwrap import TextWrapper
+from StringIO import StringIO
+import sys
+import doctest
+
+import bugdir
+import plugin
+import encoding
+
+
+class UserError(Exception):
+    def __init__(self, msg):
+        Exception.__init__(self, msg)
+
+class UnknownCommand(UserError):
+    def __init__(self, cmd):
+        Exception.__init__(self, "Unknown command '%s'" % cmd)
+        self.cmd = cmd
+
+class UsageError(Exception):
+    pass
+
+class GetHelp(Exception):
+    pass
+
+class GetCompletions(Exception):
+    def __init__(self, completions=[]):
+        msg = "Get allowed completions"
+        Exception.__init__(self, msg)
+        self.completions = completions
+
+def iter_commands():
+    for name, module in plugin.iter_plugins("becommands"):
+        yield name.replace("_", "-"), module
+
+def get_command(command_name):
+    """Retrieves the module for a user command
+
+    >>> try:
+    ...     get_command("asdf")
+    ... except UnknownCommand, e:
+    ...     print e
+    Unknown command 'asdf'
+    >>> repr(get_command("list")).startswith("<module 'becommands.list' from ")
+    True
+    """
+    cmd = plugin.get_plugin("becommands", command_name.replace("-", "_"))
+    if cmd is None:
+        raise UnknownCommand(command_name)
+    return cmd
+
+
+def execute(cmd, args, manipulate_encodings=True):
+    enc = encoding.get_encoding()
+    cmd = get_command(cmd)
+    ret = cmd.execute([a.decode(enc) for a in args],
+                      manipulate_encodings=manipulate_encodings)
+    if ret == None:
+        ret = 0
+    return ret
+
+def help(cmd=None, parser=None):
+    if cmd != None:
+        return get_command(cmd).help()
+    else:
+        cmdlist = []
+        for name, module in iter_commands():
+            cmdlist.append((name, module.__desc__))
+        longest_cmd_len = max([len(name) for name,desc in cmdlist])
+        ret = ["Bugs Everywhere - Distributed bug tracking",
+               "", "Supported commands"]
+        for name, desc in cmdlist:
+            numExtraSpaces = longest_cmd_len-len(name)
+            ret.append("be %s%*s    %s" % (name, numExtraSpaces, "", desc))
+        ret.extend(["", "Run", "  be help [command]", "for more information."])
+        longhelp = "\n".join(ret)
+        if parser == None:
+            return longhelp
+        return parser.help_str() + "\n" + longhelp
+
+def completions(cmd):
+    parser = get_command(cmd).get_parser()
+    longopts = []
+    for opt in parser.option_list:
+        longopts.append(opt.get_opt_string())
+    return longopts
+
+def raise_get_help(option, opt, value, parser):
+    raise GetHelp
+
+def raise_get_completions(option, opt, value, parser):
+    print "got completion arg"
+    if hasattr(parser, "command") and parser.command == "be":
+        comps = []
+        for command, module in iter_commands():
+            comps.append(command)
+        for opt in parser.option_list:
+            comps.append(opt.get_opt_string())
+        raise GetCompletions(comps)
+    raise GetCompletions(completions(sys.argv[1]))
+
+class CmdOptionParser(optparse.OptionParser):
+    def __init__(self, usage):
+        optparse.OptionParser.__init__(self, usage)
+        self.disable_interspersed_args()
+        self.remove_option("-h")
+        self.add_option("-h", "--help", action="callback", 
+                        callback=raise_get_help, help="Print a help message")
+        self.add_option("--complete", action="callback",
+                        callback=raise_get_completions,
+                        help="Print a list of available completions")
+
+    def error(self, message):
+        raise UsageError(message)
+
+    def iter_options(self):
+        return iter_combine([self._short_opt.iterkeys(), 
+                            self._long_opt.iterkeys()])
+
+    def help_str(self):
+        f = StringIO()
+        self.print_help(f)
+        return f.getvalue()
+
+def option_value_pairs(options, parser):
+    """
+    Iterate through OptionParser (option, value) pairs.
+    """
+    for option in [o.dest for o in parser.option_list if o.dest != None]:
+        value = getattr(options, option)
+        yield (option, value)
+
+def default_complete(options, args, parser, bugid_args={}):
+    """
+    A dud complete implementation for becommands so that the
+    --complete argument doesn't cause any problems.  Use this
+    until you've set up a command-specific complete function.
+    
+    bugid_args is an optional dict where the keys are positional
+    arguments taking bug shortnames and the values are functions for
+    filtering, since that's a common enough operation.
+    e.g. for "be open [options] BUGID"
+      bugid_args = {0: lambda bug : bug.active == False}
+    A positional argument of -1 specifies all remaining arguments
+    (e.g in the case of "be show BUGID BUGID ...").
+    """
+    for option,value in option_value_pairs(options, parser):
+        if value == "--complete":
+            raise GetCompletions()
+    if len(bugid_args.keys()) > 0:
+        max_pos_arg = max(bugid_args.keys())
+    else:
+        max_pos_arg = -1
+    for pos,value in enumerate(args):
+        if value == "--complete":
+            filter = None
+            if pos in bugid_args:
+                filter = bugid_args[pos]
+            if pos > max_pos_arg and -1 in bugid_args:
+                filter = bugid_args[-1]
+            if filter != None:
+                bugshortnames = []
+                try:
+                    bd = bugdir.BugDir(from_disk=True,
+                                       manipulate_encodings=False)
+                    bd.load_all_bugs()
+                    bugs = [bug for bug in bd if filter(bug) == True]
+                    bugshortnames = [bd.bug_shortname(bug) for bug in bugs]
+                except bugdir.NoBugDir:
+                    pass
+                raise GetCompletions(bugshortnames)
+            raise GetCompletions()
+
+def complete_path(path):
+    """List possible path completions for path."""
+    comps = glob.glob(path+"*") + glob.glob(path+"/*")
+    if len(comps) == 1 and os.path.isdir(comps[0]):
+        comps.extend(glob.glob(comps[0]+"/*"))
+    return comps
+
+def underlined(instring):
+    """Produces a version of a string that is underlined with '='
+
+    >>> underlined("Underlined String")
+    'Underlined String\\n================='
+    """
+    
+    return "%s\n%s" % (instring, "="*len(instring))
+
+def bug_from_shortname(bdir, shortname):
+    """
+    Exception translation for the command-line interface.
+    """
+    try:
+        bug = bdir.bug_from_shortname(shortname)
+    except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e:
+        raise UserError(e.message)
+    return bug
+
+def _test():
+    import doctest
+    import sys
+    doctest.testmod()
+
+if __name__ == "__main__":
+    _test()
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/comment.py b/interfaces/email/interactive/libbe/comment.py
new file mode 100644 (file)
index 0000000..41bc7e6
--- /dev/null
@@ -0,0 +1,744 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2008-2009 Chris Ball <cjb@laptop.org>
+#                         Thomas Habets <thomas@habets.pp.se>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define the Comment class for representing bug comments.
+"""
+
+import base64
+import os
+import os.path
+import sys
+import time
+import types
+try: # import core module, Python >= 2.5
+    from xml.etree import ElementTree
+except ImportError: # look for non-core module
+    from elementtree import ElementTree
+import xml.sax.saxutils
+import doctest
+
+from beuuid import uuid_gen
+from properties import Property, doc_property, local_property, \
+    defaulting_property, checked_property, cached_property, \
+    primed_property, change_hook_property, settings_property
+import settings_object
+import mapfile
+from tree import Tree
+import utility
+
+
+class InvalidShortname(KeyError):
+    def __init__(self, shortname, shortnames):
+        msg = "Invalid shortname %s\n%s" % (shortname, shortnames)
+        KeyError.__init__(self, msg)
+        self.shortname = shortname
+        self.shortnames = shortnames
+
+class InvalidXML(ValueError):
+    def __init__(self, element, comment):
+        msg = "Invalid comment xml: %s\n  %s\n" \
+            % (comment, ElementTree.tostring(element))
+        ValueError.__init__(self, msg)
+        self.element = element
+        self.comment = comment
+
+class MissingReference(ValueError):
+    def __init__(self, comment):
+        msg = "Missing reference to %s" % (comment.in_reply_to)
+        ValueError.__init__(self, msg)
+        self.reference = comment.in_reply_to
+        self.comment = comment
+
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
+
+INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
+
+def list_to_root(comments, bug, root=None,
+                 ignore_missing_references=False):
+    """
+    Convert a raw list of comments to single root comment.  We use a
+    dummy root comment by default, because there can be several
+    comment threads rooted on the same parent bug.  To simplify
+    comment interaction, we condense these threads into a single
+    thread with a Comment dummy root.  Can also be used to append
+    a list of subcomments to a non-dummy root comment, so long as
+    all the new comments are descendants of the root comment.
+    
+    No Comment method should use the dummy comment.
+    """
+    root_comments = []
+    uuid_map = {}
+    for comment in comments:
+        assert comment.uuid != None
+        uuid_map[comment.uuid] = comment
+    for comment in comments:
+        if comment.alt_id != None and comment.alt_id not in uuid_map:
+            uuid_map[comment.alt_id] = comment
+    if root == None:
+        root = Comment(bug, uuid=INVALID_UUID)
+    else:
+        uuid_map[root.uuid] = root
+    for comm in comments:
+        if comm.in_reply_to == INVALID_UUID:
+            comm.in_reply_to = None
+        rep = comm.in_reply_to
+        if rep == None or rep == bug.uuid:
+            root_comments.append(comm)
+        else:
+            parentUUID = comm.in_reply_to
+            try:
+                parent = uuid_map[parentUUID]
+                parent.add_reply(comm)
+            except KeyError, e:
+                if ignore_missing_references == True:
+                    print >> sys.stderr, \
+                        "Ignoring missing reference to %s" % parentUUID
+                    comm.in_reply_to = None
+                    root_comments.append(comm)
+                else:
+                    raise MissingReference(comm)
+    root.extend(root_comments)
+    return root
+
+def loadComments(bug, load_full=False):
+    """
+    Set load_full=True when you want to load the comment completely
+    from disk *now*, rather than waiting and lazy loading as required.
+    """
+    if bug.sync_with_disk == False:
+        raise DiskAccessRequired("load comments")
+    path = bug.get_path("comments")
+    if not os.path.exists(path):
+        return Comment(bug, uuid=INVALID_UUID)
+    comments = []
+    for uuid in os.listdir(path):
+        if uuid.startswith('.'):
+            continue
+        comm = Comment(bug, uuid, from_disk=True)
+        comm.set_sync_with_disk(bug.sync_with_disk)
+        if load_full == True:
+            comm.load_settings()
+            dummy = comm.body # force the body to load
+        comments.append(comm)
+    return list_to_root(comments, bug)
+
+def saveComments(bug):
+    if bug.sync_with_disk == False:
+        raise DiskAccessRequired("save comments")
+    for comment in bug.comment_root.traverse():
+        comment.save()
+
+
+class Comment(Tree, settings_object.SavedSettingsObject):
+    """
+    >>> c = Comment()
+    >>> c.uuid != None
+    True
+    >>> c.uuid = "some-UUID"
+    >>> print c.content_type
+    text/plain
+    """
+
+    settings_properties = []
+    required_saved_properties = []
+    _prop_save_settings = settings_object.prop_save_settings
+    _prop_load_settings = settings_object.prop_load_settings
+    def _versioned_property(settings_properties=settings_properties,
+                            required_saved_properties=required_saved_properties,
+                            **kwargs):
+        if "settings_properties" not in kwargs:
+            kwargs["settings_properties"] = settings_properties
+        if "required_saved_properties" not in kwargs:
+            kwargs["required_saved_properties"]=required_saved_properties
+        return settings_object.versioned_property(**kwargs)
+
+    @_versioned_property(name="Alt-id",
+                         doc="Alternate ID for linking imported comments.  Internally comments are linked (via In-reply-to) to the parent's UUID.  However, these UUIDs are generated internally, so Alt-id is provided as a user-controlled linking target.")
+    def alt_id(): return {}
+
+    @_versioned_property(name="Author",
+                         doc="The author of the comment")
+    def author(): return {}
+
+    @_versioned_property(name="In-reply-to",
+                         doc="UUID for parent comment or bug")
+    def in_reply_to(): return {}
+
+    @_versioned_property(name="Content-type",
+                         doc="Mime type for comment body",
+                         default="text/plain",
+                         require_save=True)
+    def content_type(): return {}
+
+    @_versioned_property(name="Date",
+                         doc="An RFC 2822 timestamp for comment creation")
+    def date(): return {}
+
+    def _get_time(self):
+        if self.date == None:
+            return None
+        return utility.str_to_time(self.date)
+    def _set_time(self, value):
+        self.date = utility.time_to_str(value)
+    time = property(fget=_get_time,
+                    fset=_set_time,
+                    doc="An integer version of .date")
+
+    def _get_comment_body(self):
+        if self.vcs != None and self.sync_with_disk == True:
+            import vcs
+            binary = not self.content_type.startswith("text/")
+            return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
+    def _set_comment_body(self, old=None, new=None, force=False):
+        if (self.vcs != None and self.sync_with_disk == True) or force==True:
+            assert new != None, "Can't save empty comment"
+            binary = not self.content_type.startswith("text/")
+            self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
+
+    @Property
+    @change_hook_property(hook=_set_comment_body)
+    @cached_property(generator=_get_comment_body)
+    @local_property("body")
+    @doc_property(doc="The meat of the comment")
+    def body(): return {}
+
+    def _get_vcs(self):
+        if hasattr(self.bug, "vcs"):
+            return self.bug.vcs
+
+    @Property
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
+    @doc_property(doc="A revision control system instance.")
+    def vcs(): return {}
+
+    def _extra_strings_check_fn(value):
+        return utility.iterable_full_of_strings(value, \
+                         alternative=settings_object.EMPTY)
+    def _extra_strings_change_hook(self, old, new):
+        self.extra_strings.sort() # to make merging easier
+        self._prop_save_settings(old, new)
+    @_versioned_property(name="extra_strings",
+                         doc="Space for an array of extra strings.  Useful for storing state for functionality implemented purely in becommands/<some_function>.py.",
+                         default=[],
+                         check_fn=_extra_strings_check_fn,
+                         change_hook=_extra_strings_change_hook,
+                         mutable=True)
+    def extra_strings(): return {}
+
+    def __init__(self, bug=None, uuid=None, from_disk=False,
+                 in_reply_to=None, body=None):
+        """
+        Set from_disk=True to load an old comment.
+        Set from_disk=False to create a new comment.
+
+        The uuid option is required when from_disk==True.
+        
+        The in_reply_to and body options are only used if
+        from_disk==False (the default).  When from_disk==True, they are
+        loaded from the bug database.
+        
+        in_reply_to should be the uuid string of the parent comment.
+        """
+        Tree.__init__(self)
+        settings_object.SavedSettingsObject.__init__(self)
+        self.bug = bug
+        self.uuid = uuid 
+        if from_disk == True: 
+            self.sync_with_disk = True
+        else:
+            self.sync_with_disk = False
+            if uuid == None:
+                self.uuid = uuid_gen()
+            self.time = int(time.time()) # only save to second precision
+            if self.vcs != None:
+                self.author = self.vcs.get_user_id()
+            self.in_reply_to = in_reply_to
+            self.body = body
+
+    def __cmp__(self, other):
+        return cmp_full(self, other)
+
+    def __str__(self):
+        """
+        >>> comm = Comment(bug=None, body="Some insightful remarks")
+        >>> comm.uuid = "com-1"
+        >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
+        >>> comm.author = "Jane Doe <jdoe@example.com>"
+        >>> print comm
+        --------- Comment ---------
+        Name: com-1
+        From: Jane Doe <jdoe@example.com>
+        Date: Thu, 20 Nov 2008 15:55:11 +0000
+        <BLANKLINE>
+        Some insightful remarks
+        """
+        return self.string()
+
+    def traverse(self, *args, **kwargs):
+        """Avoid working with the possible dummy root comment"""
+        for comment in Tree.traverse(self, *args, **kwargs):
+            if comment.uuid == INVALID_UUID:
+                continue
+            yield comment
+
+    # serializing methods
+
+    def _setting_attr_string(self, setting):
+        value = getattr(self, setting)
+        if value == None:
+            return ""
+        return str(value)
+
+    def xml(self, indent=0, shortname=None):
+        """
+        >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
+        >>> comm.uuid = "0123"
+        >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> print comm.xml(indent=2, shortname="com-1")
+          <comment>
+            <uuid>0123</uuid>
+            <short-name>com-1</short-name>
+            <author></author>
+            <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
+            <content-type>text/plain</content-type>
+            <body>Some
+        insightful
+        remarks</body>
+          </comment>
+        """
+        if shortname == None:
+            shortname = self.uuid
+        if self.content_type.startswith("text/"):
+            body = (self.body or "").rstrip('\n')
+        else:
+            maintype,subtype = self.content_type.split('/',1)
+            msg = email.mime.base.MIMEBase(maintype, subtype)
+            msg.set_payload(self.body or "")
+            email.encoders.encode_base64(msg)
+            body = base64.encodestring(self.body or "")
+        info = [("uuid", self.uuid),
+                ("alt-id", self.alt_id),
+                ("short-name", shortname),
+                ("in-reply-to", self.in_reply_to),
+                ("author", self._setting_attr_string("author")),
+                ("date", self.date),
+                ("content-type", self.content_type),
+                ("body", body)]
+        lines = ["<comment>"]
+        for (k,v) in info:
+            if v != None:
+                lines.append('  <%s>%s</%s>' % (k,xml.sax.saxutils.escape(v),k))
+        lines.append("</comment>")
+        istring = ' '*indent
+        sep = '\n' + istring
+        return istring + sep.join(lines).rstrip('\n')
+
+    def from_xml(self, xml_string, verbose=True):
+        """
+        Note: If alt-id is not given, translates any <uuid> fields to
+        <alt-id> fields.
+        >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
+        >>> commA.uuid = "0123"
+        >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> xml = commA.xml(shortname="com-1")
+        >>> commB = Comment()
+        >>> commB.from_xml(xml)
+        >>> attrs=['uuid','alt_id','in_reply_to','author','date','content_type','body']
+        >>> for attr in attrs: # doctest: +ELLIPSIS
+        ...     if getattr(commB, attr) != getattr(commA, attr):
+        ...         estr = "Mismatch on %s: '%s' should be '%s'"
+        ...         args = (attr, getattr(commB, attr), getattr(commA, attr))
+        ...         print estr % args
+        Mismatch on uuid: '...' should be '0123'
+        Mismatch on alt_id: '0123' should be 'None'
+        >>> print commB.alt_id
+        0123
+        >>> commA.author
+        >>> commB.author
+        """
+        if type(xml_string) == types.UnicodeType:
+            xml_string = xml_string.strip().encode("unicode_escape")
+        comment = ElementTree.XML(xml_string)
+        if comment.tag != "comment":
+            raise InvalidXML(comment, "root element must be <comment>")
+        tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
+        uuid = None
+        body = None
+        for child in comment.getchildren():
+            if child.tag == "short-name":
+                pass
+            elif child.tag in tags:
+                if child.text == None or len(child.text) == 0:
+                    text = settings_object.EMPTY
+                else:
+                    text = xml.sax.saxutils.unescape(child.text)
+                    text = unicode(text).decode("unicode_escape").strip()
+                if child.tag == "uuid":
+                    uuid = text
+                    continue # don't set the bug's uuid tag.
+                if child.tag == "body":
+                    body = text
+                    continue # don't set the bug's body yet.
+                else:
+                    attr_name = child.tag.replace('-','_')
+                setattr(self, attr_name, text)
+            elif verbose == True:
+                print >> sys.stderr, "Ignoring unknown tag %s in %s" \
+                    % (child.tag, comment.tag)
+        if self.alt_id == None and uuid not in [None, self.uuid]:
+            self.alt_id = uuid
+        if body != None:
+            if self.content_type.startswith("text/"):
+                self.body = body+"\n" # restore trailing newline
+            else:
+                self.body = base64.decodestring(body)
+
+    def string(self, indent=0, shortname=None):
+        """
+        >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
+        >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> print comm.string(indent=2, shortname="com-1")
+          --------- Comment ---------
+          Name: com-1
+          From: 
+          Date: Thu, 01 Jan 1970 00:00:00 +0000
+        <BLANKLINE>
+          Some
+          insightful
+          remarks
+        """
+        if shortname == None:
+            shortname = self.uuid
+        lines = []
+        lines.append("--------- Comment ---------")
+        lines.append("Name: %s" % shortname)
+        lines.append("From: %s" % (self._setting_attr_string("author")))
+        lines.append("Date: %s" % self.date)
+        lines.append("")
+        if self.content_type.startswith("text/"):
+            lines.extend((self.body or "").splitlines())
+        else:
+            lines.append("Content type %s not printable.  Try XML output instead" % self.content_type)
+        
+        istring = ' '*indent
+        sep = '\n' + istring
+        return istring + sep.join(lines).rstrip('\n')
+
+    def string_thread(self, string_method_name="string", name_map={},
+                      indent=0, flatten=True,
+                      auto_name_map=False, bug_shortname=None):
+        """
+        Return a string displaying a thread of comments.
+        bug_shortname is only used if auto_name_map == True.
+        
+        string_method_name (defaults to "string") is the name of the
+        Comment method used to generate the output string for each
+        Comment in the thread.  The method must take the arguments
+        indent and shortname.
+        
+        SIDE-EFFECT: if auto_name_map==True, calls comment_shortnames()
+        which will sort the tree by comment.time.  Avoid by calling
+          name_map = {}
+          for shortname,comment in comm.comment_shortnames(bug_shortname):
+              name_map[comment.uuid] = shortname
+          comm.sort(key=lambda c : c.author) # your sort
+          comm.string_thread(name_map=name_map)
+
+        >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
+        >>> a.time = utility.str_to_time("Thu, 20 Nov 2008 01:00:00 +0000")
+        >>> b = a.new_reply("Critique original comment")
+        >>> b.uuid = "b"
+        >>> b.time = utility.str_to_time("Thu, 20 Nov 2008 02:00:00 +0000")
+        >>> c = b.new_reply("Begin flamewar :p")
+        >>> c.uuid = "c"
+        >>> c.time = utility.str_to_time("Thu, 20 Nov 2008 03:00:00 +0000")
+        >>> d = a.new_reply("Useful examples")
+        >>> d.uuid = "d"
+        >>> d.time = utility.str_to_time("Thu, 20 Nov 2008 04:00:00 +0000")
+        >>> a.sort(key=lambda comm : comm.time)
+        >>> print a.string_thread(flatten=True)
+        --------- Comment ---------
+        Name: a
+        From: 
+        Date: Thu, 20 Nov 2008 01:00:00 +0000
+        <BLANKLINE>
+        Insightful remarks
+          --------- Comment ---------
+          Name: b
+          From: 
+          Date: Thu, 20 Nov 2008 02:00:00 +0000
+        <BLANKLINE>
+          Critique original comment
+          --------- Comment ---------
+          Name: c
+          From: 
+          Date: Thu, 20 Nov 2008 03:00:00 +0000
+        <BLANKLINE>
+          Begin flamewar :p
+        --------- Comment ---------
+        Name: d
+        From: 
+        Date: Thu, 20 Nov 2008 04:00:00 +0000
+        <BLANKLINE>
+        Useful examples
+        >>> print a.string_thread(auto_name_map=True, bug_shortname="bug-1")
+        --------- Comment ---------
+        Name: bug-1:1
+        From: 
+        Date: Thu, 20 Nov 2008 01:00:00 +0000
+        <BLANKLINE>
+        Insightful remarks
+          --------- Comment ---------
+          Name: bug-1:2
+          From: 
+          Date: Thu, 20 Nov 2008 02:00:00 +0000
+        <BLANKLINE>
+          Critique original comment
+          --------- Comment ---------
+          Name: bug-1:3
+          From: 
+          Date: Thu, 20 Nov 2008 03:00:00 +0000
+        <BLANKLINE>
+          Begin flamewar :p
+        --------- Comment ---------
+        Name: bug-1:4
+        From: 
+        Date: Thu, 20 Nov 2008 04:00:00 +0000
+        <BLANKLINE>
+        Useful examples
+        """
+        if auto_name_map == True:
+            name_map = {}
+            for shortname,comment in self.comment_shortnames(bug_shortname):
+                name_map[comment.uuid] = shortname
+        stringlist = []
+        for depth,comment in self.thread(flatten=flatten):
+            ind = 2*depth+indent
+            if comment.uuid in name_map:
+                sname = name_map[comment.uuid]
+            else:
+                sname = None
+            string_fn = getattr(comment, string_method_name)
+            stringlist.append(string_fn(indent=ind, shortname=sname))
+        return '\n'.join(stringlist)
+
+    def xml_thread(self, name_map={}, indent=0,
+                   auto_name_map=False, bug_shortname=None):
+        return self.string_thread(string_method_name="xml", name_map=name_map,
+                                  indent=indent, auto_name_map=auto_name_map,
+                                  bug_shortname=bug_shortname)
+
+    # methods for saving/loading/acessing settings and properties.
+
+    def get_path(self, *args):
+        dir = os.path.join(self.bug.get_path("comments"), self.uuid)
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["values", "body"], str(args)
+        return os.path.join(dir, *args)
+
+    def set_sync_with_disk(self, value):
+        self.sync_with_disk = value
+
+    def load_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load settings")
+        self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
+        self._setup_saved_settings()
+
+    def save_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("save settings")
+        self.vcs.mkdir(self.get_path())
+        path = self.get_path("values")
+        mapfile.map_save(self.vcs, path, self._get_saved_settings())
+
+    def save(self):
+        """
+        Save any loaded contents to disk.
+        
+        However, if self.sync_with_disk = True, then any changes are
+        automatically written to disk as soon as they happen, so
+        calling this method will just waste time (unless something
+        else has been messing with your on-disk files).
+        """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
+        assert self.body != None, "Can't save blank comment"
+        self.save_settings()
+        self._set_comment_body(new=self.body, force=True)
+        if sync_with_disk == False:
+            self.set_sync_with_disk(False)
+
+    def remove(self):
+        if self.sync_with_disk == False and self.uuid != INVALID_UUID:
+            raise DiskAccessRequired("remove")
+        for comment in self.traverse():
+            path = comment.get_path()
+            self.vcs.recursive_remove(path)
+
+    def add_reply(self, reply, allow_time_inversion=False):
+        if self.uuid != INVALID_UUID:
+            reply.in_reply_to = self.uuid
+        self.append(reply)
+
+    def new_reply(self, body=None):
+        """
+        >>> comm = Comment(bug=None, body="Some insightful remarks")
+        >>> repA = comm.new_reply("Critique original comment")
+        >>> repB = repA.new_reply("Begin flamewar :p")
+        >>> repB.in_reply_to == repA.uuid
+        True
+        """
+        reply = Comment(self.bug, body=body)
+        if self.bug != None:
+            reply.set_sync_with_disk(self.bug.sync_with_disk)
+        if reply.sync_with_disk == True:
+            reply.save()
+        self.add_reply(reply)
+        return reply
+
+    def comment_shortnames(self, bug_shortname=None):
+        """
+        Iterate through (id, comment) pairs, in time order.
+        (This is a user-friendly id, not the comment uuid).
+
+        SIDE-EFFECT : will sort the comment tree by comment.time
+
+        >>> a = Comment(bug=None, uuid="a")
+        >>> b = a.new_reply()
+        >>> b.uuid = "b"
+        >>> c = b.new_reply()
+        >>> c.uuid = "c"
+        >>> d = a.new_reply()
+        >>> d.uuid = "d"
+        >>> for id,name in a.comment_shortnames("bug-1"):
+        ...     print id, name.uuid
+        bug-1:1 a
+        bug-1:2 b
+        bug-1:3 c
+        bug-1:4 d
+        """
+        if bug_shortname == None:
+            bug_shortname = ""
+        self.sort(key=lambda comm : comm.time)
+        for num,comment in enumerate(self.traverse()):
+            yield ("%s:%d" % (bug_shortname, num+1), comment)
+
+    def comment_from_shortname(self, comment_shortname, *args, **kwargs):
+        """
+        Use a comment shortname to look up a comment.
+        >>> a = Comment(bug=None, uuid="a")
+        >>> b = a.new_reply()
+        >>> b.uuid = "b"
+        >>> c = b.new_reply()
+        >>> c.uuid = "c"
+        >>> d = a.new_reply()
+        >>> d.uuid = "d"
+        >>> comm = a.comment_from_shortname("bug-1:3", bug_shortname="bug-1")
+        >>> id(comm) == id(c)
+        True
+        """
+        for cur_name, comment in self.comment_shortnames(*args, **kwargs):
+            if comment_shortname == cur_name:
+                return comment
+        raise InvalidShortname(comment_shortname,
+                               list(self.comment_shortnames(*args, **kwargs)))
+
+    def comment_from_uuid(self, uuid):
+        """
+        Use a comment shortname to look up a comment.
+        >>> a = Comment(bug=None, uuid="a")
+        >>> b = a.new_reply()
+        >>> b.uuid = "b"
+        >>> c = b.new_reply()
+        >>> c.uuid = "c"
+        >>> d = a.new_reply()
+        >>> d.uuid = "d"
+        >>> comm = a.comment_from_uuid("d")
+        >>> id(comm) == id(d)
+        True
+        """
+        for comment in self.traverse():
+            if comment.uuid == uuid:
+                return comment
+        raise KeyError(uuid)
+
+def cmp_attr(comment_1, comment_2, attr, invert=False):
+    """
+    Compare a general attribute between two comments using the conventional
+    comparison rule for that attribute type.  If invert == True, sort
+    *against* that convention.
+    >>> attr="author"
+    >>> commentA = Comment()
+    >>> commentB = Comment()
+    >>> commentA.author = "John Doe"
+    >>> commentB.author = "Jane Doe"
+    >>> cmp_attr(commentA, commentB, attr) > 0
+    True
+    >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
+    True
+    >>> commentB.author = "John Doe"
+    >>> cmp_attr(commentA, commentB, attr) == 0
+    True
+    """
+    if not hasattr(comment_2, attr) :
+        return 1
+    val_1 = getattr(comment_1, attr)
+    val_2 = getattr(comment_2, attr)
+    if val_1 == None: val_1 = None
+    if val_2 == None: val_2 = None
+    
+    if invert == True :
+        return -cmp(val_1, val_2)
+    else :
+        return cmp(val_1, val_2)
+
+# alphabetical rankings (a < z)
+cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
+cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
+cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
+cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
+cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
+# chronological rankings (newer < older)
+cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
+
+DEFAULT_CMP_FULL_CMP_LIST = \
+    (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
+     cmp_uuid)
+
+class CommentCompoundComparator (object):
+    def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
+        self.cmp_list = cmp_list
+    def __call__(self, comment_1, comment_2):
+        for comparison in self.cmp_list :
+            val = comparison(comment_1, comment_2)
+            if val != 0 :
+                return val
+        return 0
+        
+cmp_full = CommentCompoundComparator()
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/config.py b/interfaces/email/interactive/libbe/config.py
new file mode 100644 (file)
index 0000000..fb5a028
--- /dev/null
@@ -0,0 +1,89 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Create, save, and load the per-user config file at path().
+"""
+
+import ConfigParser
+import codecs
+import locale
+import os.path
+import sys
+import doctest
+
+
+default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
+
+def path():
+    """Return the path to the per-user config file"""
+    return os.path.expanduser("~/.bugs_everywhere")
+
+def set_val(name, value, section="DEFAULT", encoding=None):
+    """Set a value in the per-user config file
+
+    :param name: The name of the value to set
+    :param value: The new value to set (or None to delete the value)
+    :param section: The section to store the name/value in
+    """
+    if encoding == None:
+        encoding = default_encoding
+    config = ConfigParser.ConfigParser()
+    if os.path.exists(path()) == False: # touch file or config 
+        open(path(), "w").close()       # read chokes on missing file
+    f = codecs.open(path(), "r", encoding)
+    config.readfp(f, path())
+    f.close()
+    if value is not None:
+        config.set(section, name, value)
+    else:
+        config.remove_option(section, name)
+    f = codecs.open(path(), "w", encoding)
+    config.write(f)
+    f.close()
+
+def get_val(name, section="DEFAULT", default=None, encoding=None):
+    """
+    Get a value from the per-user config file
+
+    :param name: The name of the value to get
+    :section: The section that the name is in
+    :return: The value, or None
+    >>> get_val("junk") is None
+    True
+    >>> set_val("junk", "random")
+    >>> get_val("junk")
+    u'random'
+    >>> set_val("junk", None)
+    >>> get_val("junk") is None
+    True
+    """
+    if os.path.exists(path()):
+        if encoding == None:
+            encoding = default_encoding
+        config = ConfigParser.ConfigParser()
+        f = codecs.open(path(), "r", encoding)
+        config.readfp(f, path())
+        f.close()
+        try:
+            return config.get(section, name)
+        except ConfigParser.NoOptionError:
+            return default
+    else:
+        return default
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/darcs.py b/interfaces/email/interactive/libbe/darcs.py
new file mode 100644 (file)
index 0000000..16005f2
--- /dev/null
@@ -0,0 +1,184 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Darcs backend.
+"""
+
+import codecs
+import os
+import re
+import sys
+try: # import core module, Python >= 2.5
+    from xml.etree import ElementTree
+except ImportError: # look for non-core module
+    from elementtree import ElementTree
+from xml.sax.saxutils import unescape
+import doctest
+import unittest
+
+import vcs
+
+
+def new():
+    return Darcs()
+
+class Darcs(vcs.VCS):
+    name="darcs"
+    client="darcs"
+    versioned=True
+    def _vcs_help(self):
+        status,output,error = self._u_invoke_client("--help")
+        return output
+    def _vcs_detect(self, path):
+        if self._u_search_parent_directories(path, "_darcs") != None :
+            return True
+        return False 
+    def _vcs_root(self, path):
+        """Find the root of the deepest repository containing path."""
+        # Assume that nothing funny is going on; in particular, that we aren't
+        # dealing with a bare repo.
+        if os.path.isdir(path) != True:
+            path = os.path.dirname(path)
+        darcs_dir = self._u_search_parent_directories(path, "_darcs")
+        if darcs_dir == None:
+            return None
+        return os.path.dirname(darcs_dir)
+    def _vcs_init(self, path):
+        self._u_invoke_client("init", directory=path)
+    def _vcs_get_user_id(self):
+        # following http://darcs.net/manual/node4.html#SECTION00410030000000000000
+        # as of June 29th, 2009
+        if self.rootdir == None:
+            return None
+        darcs_dir = os.path.join(self.rootdir, "_darcs")
+        if darcs_dir != None:
+            for pref_file in ["author", "email"]:
+                pref_path = os.path.join(darcs_dir, "prefs", pref_file)
+                if os.path.exists(pref_path):
+                    return self.get_file_contents(pref_path)
+        for env_variable in ["DARCS_EMAIL", "EMAIL"]:
+            if env_variable in os.environ:
+                return os.environ[env_variable]
+        return None
+    def _vcs_set_user_id(self, value):
+        if self.rootdir == None:
+            self.root(".")
+            if self.rootdir == None:
+                raise vcs.SettingIDnotSupported
+        author_path = os.path.join(self.rootdir, "_darcs", "prefs", "author")
+        f = codecs.open(author_path, "w", self.encoding)
+        f.write(value)
+        f.close()
+    def _vcs_add(self, path):
+        if os.path.isdir(path):
+            return
+        self._u_invoke_client("add", path)
+    def _vcs_remove(self, path):
+        if not os.path.isdir(self._u_abspath(path)):
+            os.remove(os.path.join(self.rootdir, path)) # darcs notices removal
+    def _vcs_update(self, path):
+        pass # darcs notices changes
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
+        if revision == None:
+            return vcs.VCS._vcs_get_file_contents(self, path, revision,
+                                              binary=binary)
+        else:
+            try:
+                return self._u_invoke_client("show", "contents", "--patch", revision, path)
+            except vcs.CommandError:
+                # Darcs versions < 2.0.0pre2 lack the "show contents" command
+
+                status,output,error = self._u_invoke_client("diff", "--unified",
+                                                            "--from-patch",
+                                                            revision, path)
+                major_patch = output
+                status,output,error = self._u_invoke_client("diff", "--unified",
+                                                            "--patch",
+                                                            revision, path)
+                target_patch = output
+                
+                # "--output -" to be supported in GNU patch > 2.5.9
+                # but that hasn't been released as of June 30th, 2009.
+
+                # Rewrite path to status before the patch we want
+                args=["patch", "--reverse", path]
+                status,output,error = self._u_invoke(args, stdin=major_patch)
+                # Now apply the patch we want
+                args=["patch", path]
+                status,output,error = self._u_invoke(args, stdin=target_patch)
+
+                if os.path.exists(os.path.join(self.rootdir, path)) == True:
+                    contents = vcs.VCS._vcs_get_file_contents(self, path,
+                                                          binary=binary)
+                else:
+                    contents = ""
+
+                # Now restore path to it's current incarnation
+                args=["patch", "--reverse", path]
+                status,output,error = self._u_invoke(args, stdin=target_patch)
+                args=["patch", path]
+                status,output,error = self._u_invoke(args, stdin=major_patch)
+                current_contents = vcs.VCS._vcs_get_file_contents(self, path,
+                                                              binary=binary)
+                return contents
+    def _vcs_duplicate_repo(self, directory, revision=None):
+        if revision==None:
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+        else:
+            self._u_invoke_client("put", "--to-patch", revision, directory)
+    def _vcs_commit(self, commitfile, allow_empty=False):
+        id = self.get_user_id()
+        if '@' not in id:
+            id = "%s <%s@invalid.com>" % (id, id)
+        args = ['record', '--all', '--author', id, '--logfile', commitfile]
+        status,output,error = self._u_invoke_client(*args)
+        empty_strings = ["No changes!"]
+        if self._u_any_in_string(empty_strings, output) == True:
+            if allow_empty == False:
+                raise vcs.EmptyCommit()
+            # note that darcs does _not_ make an empty revision.
+            # this returns the last non-empty revision id...
+            revision = self._vcs_revision_id(-1)
+        else:
+            revline = re.compile("Finished recording patch '(.*)'")
+            match = revline.search(output)
+            assert match != None, output+error
+            assert len(match.groups()) == 1
+            revision = match.groups()[0]
+        return revision
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("changes", "--xml")
+        revisions = []
+        xml_str = output.encode("unicode_escape").replace(r"\n", "\n")
+        element = ElementTree.XML(xml_str)
+        assert element.tag == "changelog", element.tag
+        for patch in element.getchildren():
+            assert patch.tag == "patch", patch.tag
+            for child in patch.getchildren():
+                if child.tag == "name":
+                    text = unescape(unicode(child.text).decode("unicode_escape").strip())
+                    revisions.append(text)
+        revisions.reverse()
+        try:
+            return revisions[index]
+        except IndexError:
+            return None
+\f    
+vcs.make_vcs_testcase_subclasses(Darcs, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/diff.py b/interfaces/email/interactive/libbe/diff.py
new file mode 100644 (file)
index 0000000..9253a23
--- /dev/null
@@ -0,0 +1,419 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""Compare two bug trees."""
+
+import difflib
+import doctest
+
+from libbe import bugdir, bug, settings_object, tree
+from libbe.utility import time_to_str
+
+
+class DiffTree (tree.Tree):
+    """
+    A tree holding difference data for easy report generation.
+    >>> bugdir = DiffTree("bugdir")
+    >>> bdsettings = DiffTree("settings", data="target: None -> 1.0")
+    >>> bugdir.append(bdsettings)
+    >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
+    >>> bugdir.append(bugs)
+    >>> new = DiffTree("new", "new bugs: ABC, DEF")
+    >>> bugs.append(new)
+    >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
+    >>> bugs.append(rem)
+    >>> print bugdir.report_string()
+    target: None -> 1.0
+    bug-count: 5 -> 6
+      new bugs: ABC, DEF
+      removed bugs: RST, UVW
+    >>> print "\\n".join(bugdir.paths())
+    bugdir
+    bugdir/settings
+    bugdir/bugs
+    bugdir/bugs/new
+    bugdir/bugs/rem
+    >>> bugdir.child_by_path("/") == bugdir
+    True
+    >>> bugdir.child_by_path("/bugs") == bugs
+    True
+    >>> bugdir.child_by_path("/bugs/rem") == rem
+    True
+    >>> bugdir.child_by_path("bugdir") == bugdir
+    True
+    >>> bugdir.child_by_path("bugdir/") == bugdir
+    True
+    >>> bugdir.child_by_path("bugdir/bugs") == bugs
+    True
+    >>> bugdir.child_by_path("/bugs").masked = True
+    >>> print bugdir.report_string()
+    target: None -> 1.0
+    """
+    def __init__(self, name, data=None, data_part_fn=str,
+                 requires_children=False, masked=False):
+        tree.Tree.__init__(self)
+        self.name = name
+        self.data = data
+        self.data_part_fn = data_part_fn
+        self.requires_children = requires_children
+        self.masked = masked
+    def paths(self, parent_path=None):
+        paths = []
+        if parent_path == None:
+            path = self.name
+        else:
+            path = "%s/%s" % (parent_path, self.name)
+        paths.append(path)
+        for child in self:
+            paths.extend(child.paths(path))
+        return paths
+    def child_by_path(self, path):
+        if hasattr(path, "split"): # convert string path to a list of names
+            names = path.split("/")
+            if names[0] == "":
+                names[0] = self.name # replace root with self
+            if len(names) > 1 and names[-1] == "":
+                names = names[:-1] # strip empty tail
+        else: # it was already an array
+            names = path
+        assert len(names) > 0, path
+        if names[0] == self.name:
+            if len(names) == 1:
+                return self
+            for child in self:
+                if names[1] == child.name:
+                    return child.child_by_path(names[1:])
+        if len(names) == 1:
+            raise KeyError, "%s doesn't match '%s'" % (names, self.name)
+        raise KeyError, "%s points to child not in %s" % (names, [c.name for c in self])
+    def report_string(self):
+        return "\n".join(self.report())
+    def report(self, root=None, parent=None, depth=0):
+        if root == None:
+            root = self.make_root()
+        if self.masked == True:
+            return None
+        data_part = self.data_part(depth)
+        if self.requires_children == True and len(self) == 0:
+            pass
+        else:
+            self.join(root, parent, data_part)
+            if data_part != None:
+                depth += 1
+        for child in self:
+            child.report(root, self, depth)
+        return root
+    def make_root(self):
+        return []
+    def join(self, root, parent, data_part):
+        if data_part != None:
+            root.append(data_part)
+    def data_part(self, depth, indent=True):
+        if self.data == None:
+            return None
+        if hasattr(self, "_cached_data_part"):
+            return self._cached_data_part
+        data_part = self.data_part_fn(self.data)
+        if indent == True:
+            data_part_lines = data_part.splitlines()
+            indent = "  "*(depth)
+            line_sep = "\n"+indent
+            data_part = indent+line_sep.join(data_part_lines)
+        self._cached_data_part = data_part
+        return data_part
+
+class Diff (object):
+    """
+    Difference tree generator for BugDirs.
+    >>> import copy
+    >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+    >>> bd.user_id = "John Doe <j@doe.com>"
+    >>> bd_new = copy.deepcopy(bd)
+    >>> bd_new.target = "1.0"
+    >>> a = bd_new.bug_from_uuid("a")
+    >>> rep = a.comment_root.new_reply("I'm closing this bug")
+    >>> rep.uuid = "acom"
+    >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+    >>> a.status = "closed"
+    >>> b = bd_new.bug_from_uuid("b")
+    >>> bd_new.remove_bug(b)
+    >>> c = bd_new.new_bug("c", "Bug C")
+    >>> d = Diff(bd, bd_new)
+    >>> r = d.report_tree()
+    >>> print "\\n".join(r.paths())
+    bugdir
+    bugdir/settings
+    bugdir/bugs
+    bugdir/bugs/new
+    bugdir/bugs/new/c
+    bugdir/bugs/rem
+    bugdir/bugs/rem/b
+    bugdir/bugs/mod
+    bugdir/bugs/mod/a
+    bugdir/bugs/mod/a/settings
+    bugdir/bugs/mod/a/comments
+    bugdir/bugs/mod/a/comments/new
+    bugdir/bugs/mod/a/comments/new/acom
+    bugdir/bugs/mod/a/comments/rem
+    bugdir/bugs/mod/a/comments/mod
+    >>> print r.report_string()
+    Changed bug directory settings:
+      target: None -> 1.0
+    New bugs:
+      c:om: Bug C
+    Removed bugs:
+      b:cm: Bug B
+    Modified bugs:
+      a:cm: Bug A
+        Changed bug settings:
+          status: open -> closed
+        New comments:
+          from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
+            I'm closing this bug...
+    >>> bd.cleanup()
+    """
+    def __init__(self, old_bugdir, new_bugdir):
+        self.old_bugdir = old_bugdir
+        self.new_bugdir = new_bugdir
+
+    # data assembly methods
+
+    def _changed_bugs(self):
+        """
+        Search for differences in all bugs between .old_bugdir and
+        .new_bugdir.  Returns
+          (added_bugs, modified_bugs, removed_bugs)
+        where added_bugs and removed_bugs are lists of added and
+        removed bugs respectively.  modified_bugs is a list of
+        (old_bug,new_bug) pairs.
+        """
+        if hasattr(self, "__changed_bugs"):
+            return self.__changed_bugs
+        added = []
+        removed = []
+        modified = []
+        for uuid in self.new_bugdir.list_uuids():
+            new_bug = self.new_bugdir.bug_from_uuid(uuid)
+            try:
+                old_bug = self.old_bugdir.bug_from_uuid(uuid)
+            except KeyError:
+                added.append(new_bug)
+            else:
+                if old_bug.sync_with_disk == True:
+                    old_bug.load_comments()
+                if new_bug.sync_with_disk == True:
+                    new_bug.load_comments()
+                if old_bug != new_bug:
+                    modified.append((old_bug, new_bug))
+        for uuid in self.old_bugdir.list_uuids():
+            if not self.new_bugdir.has_bug(uuid):
+                old_bug = self.old_bugdir.bug_from_uuid(uuid)
+                removed.append(old_bug)
+        added.sort()
+        removed.sort()
+        modified.sort(self._bug_modified_cmp)
+        self.__changed_bugs = (added, modified, removed)
+        return self.__changed_bugs
+    def _bug_modified_cmp(self, left, right):
+        return cmp(left[1], right[1])
+    def _changed_comments(self, old, new):
+        """
+        Search for differences in all loaded comments between the bugs
+        old and new.  Returns
+          (added_comments, modified_comments, removed_comments)
+        analogous to ._changed_bugs.
+        """
+        if hasattr(self, "__changed_comments"):
+            if new.uuid in self.__changed_comments:
+                return self.__changed_comments[new.uuid]
+        else:
+            self.__changed_comments = {}
+        added = []
+        removed = []
+        modified = []
+        old.comment_root.sort(key=lambda comm : comm.time)
+        new.comment_root.sort(key=lambda comm : comm.time)
+        old_comment_ids = [c.uuid for c in old.comments()]
+        new_comment_ids = [c.uuid for c in new.comments()]
+        for uuid in new_comment_ids:
+            new_comment = new.comment_from_uuid(uuid)
+            try:
+                old_comment = old.comment_from_uuid(uuid)
+            except KeyError:
+                added.append(new_comment)
+            else:
+                if old_comment != new_comment:
+                    modified.append((old_comment, new_comment))
+        for uuid in old_comment_ids:
+            if uuid not in new_comment_ids:
+                new_comment = new.comment_from_uuid(uuid)
+                removed.append(new_comment)
+        self.__changed_comments[new.uuid] = (added, modified, removed)
+        return self.__changed_comments[new.uuid]
+    def _attribute_changes(self, old, new, attributes):
+        """
+        Take two objects old and new, and compare the value of *.attr
+        for attr in the list attribute names.  Returns a list of
+          (attr_name, old_value, new_value)
+        tuples.
+        """
+        change_list = []
+        for attr in attributes:
+            old_value = getattr(old, attr)
+            new_value = getattr(new, attr)
+            if old_value != new_value:
+                change_list.append((attr, old_value, new_value))
+        if len(change_list) >= 0:
+            return change_list
+        return None
+    def _settings_properties_attribute_changes(self, old, new,
+                                              hidden_properties=[]):
+        properties = sorted(new.settings_properties)
+        for p in hidden_properties:
+            properties.remove(p)
+        attributes = [settings_object.setting_name_to_attr_name(None, p)
+                      for p in properties]
+        return self._attribute_changes(old, new, attributes)
+    def _bugdir_attribute_changes(self):
+        return self._settings_properties_attribute_changes( \
+            self.old_bugdir, self.new_bugdir,
+            ["vcs_name"]) # tweaked by bugdir.duplicate_bugdir
+    def _bug_attribute_changes(self, old, new):
+        return self._settings_properties_attribute_changes(old, new)
+    def _comment_attribute_changes(self, old, new):
+        return self._settings_properties_attribute_changes(old, new)
+
+    # report generation methods
+
+    def report_tree(self, diff_tree=DiffTree):
+        """
+        Pretty bare to make it easy to adjust to specific cases.  You
+        can pass in a DiffTree subclass via diff_tree to override the
+        default report assembly process.
+        """
+        if hasattr(self, "__report_tree"):
+            return self.__report_tree
+        bugdir_settings = sorted(self.new_bugdir.settings_properties)
+        bugdir_settings.remove("vcs_name") # tweaked by bugdir.duplicate_bugdir
+        root = diff_tree("bugdir")
+        bugdir_attribute_changes = self._bugdir_attribute_changes()
+        if len(bugdir_attribute_changes) > 0:
+            bugdir = diff_tree("settings", bugdir_attribute_changes,
+                               self.bugdir_attribute_change_string)
+            root.append(bugdir)
+        bug_root = diff_tree("bugs")
+        root.append(bug_root)
+        add,mod,rem = self._changed_bugs()
+        bnew = diff_tree("new", "New bugs:", requires_children=True)
+        bug_root.append(bnew)
+        for bug in add:
+            b = diff_tree(bug.uuid, bug, self.bug_add_string)
+            bnew.append(b)
+        brem = diff_tree("rem", "Removed bugs:", requires_children=True)
+        bug_root.append(brem)
+        for bug in rem:
+            b = diff_tree(bug.uuid, bug, self.bug_rem_string)
+            brem.append(b)
+        bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
+        bug_root.append(bmod)
+        for old,new in mod:
+            b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
+            bmod.append(b)
+            bug_attribute_changes = self._bug_attribute_changes(old, new)
+            if len(bug_attribute_changes) > 0:
+                bset = diff_tree("settings", bug_attribute_changes,
+                                 self.bug_attribute_change_string)
+                b.append(bset)
+            if old.summary != new.summary:
+                data = (old.summary, new.summary)
+                bsum = diff_tree("summary", data, self.bug_summary_change_string)
+                b.append(bsum)
+            cr = diff_tree("comments")
+            b.append(cr)
+            a,m,d = self._changed_comments(old, new)
+            cnew = diff_tree("new", "New comments:", requires_children=True)
+            for comment in a:
+                c = diff_tree(comment.uuid, comment, self.comment_add_string)
+                cnew.append(c)
+            crem = diff_tree("rem", "Removed comments:",requires_children=True)
+            for comment in d:
+                c = diff_tree(comment.uuid, comment, self.comment_rem_string)
+                crem.append(c)
+            cmod = diff_tree("mod","Modified comments:",requires_children=True)
+            for o,n in m:
+                c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
+                cmod.append(c)
+                comm_attribute_changes = self._comment_attribute_changes(o, n)
+                if len(comm_attribute_changes) > 0:
+                    cset = diff_tree("settings", comm_attribute_changes,
+                                     self.comment_attribute_change_string)
+                if o.body != n.body:
+                    data = (o.body, n.body)
+                    cbody = diff_tree("cbody", data,
+                                      self.comment_body_change_string)
+                    c.append(cbody)
+            cr.extend([cnew, crem, cmod])
+        self.__report_tree = root
+        return self.__report_tree
+
+    # change data -> string methods.
+    # Feel free to play with these in subclasses.
+
+    def attribute_change_string(self, attribute_changes, indent=0):
+        indent_string = "  "*indent
+        change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
+        for i,change_string in enumerate(change_strings):
+            change_strings[i] = indent_string+change_string
+        return u"\n".join(change_strings)
+    def bugdir_attribute_change_string(self, attribute_changes):
+        return "Changed bug directory settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def bug_attribute_change_string(self, attribute_changes):
+        return "Changed bug settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def comment_attribute_change_string(self, attribute_changes):
+        return "Changed comment settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def bug_add_string(self, bug):
+        return bug.string(shortlist=True)
+    def bug_rem_string(self, bug):
+        return bug.string(shortlist=True)
+    def bug_mod_string(self, bugs):
+        old_bug,new_bug = bugs
+        return new_bug.string(shortlist=True)
+    def bug_summary_change_string(self, summaries):
+        old_summary,new_summary = summaries
+        return "summary changed:\n  %s\n  %s" % (old_summary, new_summary)
+    def _comment_summary_string(self, comment):
+        return "from %s on %s" % (comment.author, time_to_str(comment.time))
+    def comment_add_string(self, comment):
+        summary = self._comment_summary_string(comment)
+        first_line = comment.body.splitlines()[0]
+        return "%s\n  %s..." % (summary, first_line)
+    def comment_rem_string(self, comment):
+        summary = self._comment_summary_string(comment)
+        first_line = comment.body.splitlines()[0]
+        return "%s\n  %s..." % (summary, first_line)
+    def comment_mod_string(self, comments):
+        old_comment,new_comment = comments
+        return self._comment_summary_string(new_comment)
+    def comment_body_change_string(self, bodies):
+        old_body,new_body = bodies
+        return difflib.unified_diff(old_body, new_body)
+
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/editor.py b/interfaces/email/interactive/libbe/editor.py
new file mode 100644 (file)
index 0000000..ec41006
--- /dev/null
@@ -0,0 +1,108 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define editor_string(), a function that invokes an editor to accept
+user-produced text as a string.
+"""
+
+import codecs
+import locale
+import os
+import sys
+import tempfile
+import doctest
+
+
+default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
+
+comment_marker = u"== Anything below this line will be ignored\n"
+
+class CantFindEditor(Exception):
+    def __init__(self):
+        Exception.__init__(self, "Can't find editor to get string from")
+
+def editor_string(comment=None, encoding=None):
+    """Invokes the editor, and returns the user-produced text as a string
+
+    >>> if "EDITOR" in os.environ:
+    ...     del os.environ["EDITOR"]
+    >>> if "VISUAL" in os.environ:
+    ...     del os.environ["VISUAL"]
+    >>> editor_string()
+    Traceback (most recent call last):
+    CantFindEditor: Can't find editor to get string from
+    >>> os.environ["EDITOR"] = "echo bar > "
+    >>> editor_string()
+    u'bar\\n'
+    >>> os.environ["VISUAL"] = "echo baz > "
+    >>> editor_string()
+    u'baz\\n'
+    >>> del os.environ["EDITOR"]
+    >>> del os.environ["VISUAL"]
+    """
+    if encoding == None:
+        encoding = default_encoding
+    for name in ('VISUAL', 'EDITOR'):
+        try:
+            editor = os.environ[name]
+            break
+        except KeyError:
+            pass
+    else:
+        raise CantFindEditor()
+    fhandle, fname = tempfile.mkstemp()
+    try:
+        if comment is not None:
+            cstring = u'\n'+comment_string(comment)
+            os.write(fhandle, cstring.encode(encoding))
+        os.close(fhandle)
+        oldmtime = os.path.getmtime(fname)
+        os.system("%s %s" % (editor, fname))
+        f = codecs.open(fname, "r", encoding)
+        output = trimmed_string(f.read())
+        f.close()
+        if output.rstrip('\n') == "":
+            output = None
+    finally:
+        os.unlink(fname)
+    return output
+
+
+def comment_string(comment):
+    """
+    >>> comment_string('hello') == comment_marker+"hello"
+    True
+    """
+    return comment_marker + comment
+
+
+def trimmed_string(instring):
+    """
+    >>> trimmed_string("hello\\n"+comment_marker)
+    u'hello\\n'
+    >>> trimmed_string("hi!\\n" + comment_string('Booga'))
+    u'hi!\\n'
+    """
+    out = []
+    for line in instring.splitlines(True):
+        if line.startswith(comment_marker):
+            break
+        out.append(line)
+    return ''.join(out)
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/encoding.py b/interfaces/email/interactive/libbe/encoding.py
new file mode 100644 (file)
index 0000000..fd513b5
--- /dev/null
@@ -0,0 +1,61 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Support input/output/filesystem encodings (e.g. UTF-8).
+"""
+
+import codecs
+import locale
+import sys
+import doctest
+
+
+ENCODING = None # override get_encoding() output by setting this
+
+def get_encoding():
+    """
+    Guess a useful input/output/filesystem encoding...  Maybe we need
+    seperate encodings for input/output and filesystem?  Hmm...
+    """
+    if ENCODING != None:
+        return ENCODING
+    encoding = locale.getpreferredencoding() or sys.getdefaultencoding()
+    if sys.platform != 'win32' or sys.version_info[:2] > (2, 3):
+        encoding = locale.getlocale(locale.LC_TIME)[1] or encoding
+        # Python 2.3 on windows doesn't know about 'XYZ' alias for 'cpXYZ'
+    return encoding
+
+def known_encoding(encoding):
+    """
+    >>> known_encoding("highly-unlikely-encoding")
+    False
+    >>> known_encoding(get_encoding())
+    True
+    """
+    try:
+        codecs.lookup(encoding)
+        return True
+    except LookupError:
+        return False
+
+def set_IO_stream_encodings(encoding):
+    sys.stdin = codecs.getreader(encoding)(sys.__stdin__)
+    sys.stdout = codecs.getwriter(encoding)(sys.__stdout__)
+    sys.stderr = codecs.getwriter(encoding)(sys.__stderr__)
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/git.py b/interfaces/email/interactive/libbe/git.py
new file mode 100644 (file)
index 0000000..3abe3b8
--- /dev/null
@@ -0,0 +1,148 @@
+# Copyright (C) 2008-2009 Ben Finney <ben+python@benfinney.id.au>
+#                         Chris Ball <cjb@laptop.org>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Git backend.
+"""
+
+import os
+import re
+import sys
+import unittest
+import doctest
+
+import vcs
+
+
+def new():
+    return Git()
+
+class Git(vcs.VCS):
+    name="git"
+    client="git"
+    versioned=True
+    def _vcs_help(self):
+        status,output,error = self._u_invoke_client("--help")
+        return output
+    def _vcs_detect(self, path):
+        if self._u_search_parent_directories(path, ".git") != None :
+            return True
+        return False 
+    def _vcs_root(self, path):
+        """Find the root of the deepest repository containing path."""
+        # Assume that nothing funny is going on; in particular, that we aren't
+        # dealing with a bare repo.
+        if os.path.isdir(path) != True:
+            path = os.path.dirname(path)
+        status,output,error = self._u_invoke_client("rev-parse", "--git-dir",
+                                                    directory=path)
+        gitdir = os.path.join(path, output.rstrip('\n'))
+        dirname = os.path.abspath(os.path.dirname(gitdir))
+        return dirname
+    def _vcs_init(self, path):
+        self._u_invoke_client("init", directory=path)
+    def _vcs_get_user_id(self):
+        status,output,error = \
+            self._u_invoke_client("config", "user.name", expect=(0,1))
+        if status == 0:
+            name = output.rstrip('\n')
+        else:
+            name = ""
+        status,output,error = \
+            self._u_invoke_client("config", "user.email", expect=(0,1))
+        if status == 0:
+            email = output.rstrip('\n')
+        else:
+            email = ""
+        if name != "" or email != "": # got something!
+            # guess missing info, if necessary
+            if name == "":
+                name = self._u_get_fallback_username()
+            if email == "":
+                email = self._u_get_fallback_email()
+            return self._u_create_id(name, email)
+        return None # Git has no infomation
+    def _vcs_set_user_id(self, value):
+        name,email = self._u_parse_id(value)
+        if email != None:
+            self._u_invoke_client("config", "user.email", email)
+        self._u_invoke_client("config", "user.name", name)
+    def _vcs_add(self, path):
+        if os.path.isdir(path):
+            return
+        self._u_invoke_client("add", path)
+    def _vcs_remove(self, path):
+        if not os.path.isdir(self._u_abspath(path)):
+            self._u_invoke_client("rm", "-f", path)
+    def _vcs_update(self, path):
+        self._vcs_add(path)
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
+        if revision == None:
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
+        else:
+            arg = "%s:%s" % (revision,path)
+            status,output,error = self._u_invoke_client("show", arg)
+            return output
+    def _vcs_duplicate_repo(self, directory, revision=None):
+        if revision==None:
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+        else:
+            #self._u_invoke_client("archive", revision, directory) # makes tarball
+            self._u_invoke_client("clone", "--no-checkout",".",directory)
+            self._u_invoke_client("checkout", revision, directory=directory)
+    def _vcs_commit(self, commitfile, allow_empty=False):
+        args = ['commit', '--all', '--file', commitfile]
+        if allow_empty == True:
+            args.append("--allow-empty")
+            status,output,error = self._u_invoke_client(*args)
+        else:
+            kwargs = {"expect":(0,1)}
+            status,output,error = self._u_invoke_client(*args, **kwargs)
+            strings = ["nothing to commit",
+                       "nothing added to commit"]
+            if self._u_any_in_string(strings, output) == True:
+                raise vcs.EmptyCommit()
+        revision = None
+        revline = re.compile("(.*) (.*)[:\]] (.*)")
+        match = revline.search(output)
+        assert match != None, output+error
+        assert len(match.groups()) == 3
+        revision = match.groups()[1]
+        full_revision = self._vcs_revision_id(-1)
+        assert full_revision.startswith(revision), \
+            "Mismatched revisions:\n%s\n%s" % (revision, full_revision)
+        return full_revision
+    def _vcs_revision_id(self, index):
+        args = ["rev-list", "--first-parent", "--reverse", "HEAD"]
+        kwargs = {"expect":(0,128)}
+        status,output,error = self._u_invoke_client(*args, **kwargs)
+        if status == 128:
+            if error.startswith("fatal: ambiguous argument 'HEAD': unknown "):
+                return None
+            raise vcs.CommandError(args, status, stdout="", stderr=error)
+        commits = output.splitlines()
+        try:
+            return commits[index]
+        except IndexError:
+            return None
+
+\f    
+vcs.make_vcs_testcase_subclasses(Git, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/hg.py b/interfaces/email/interactive/libbe/hg.py
new file mode 100644 (file)
index 0000000..f8f8121
--- /dev/null
@@ -0,0 +1,103 @@
+# Copyright (C) 2007-2009 Aaron Bentley and Panometrics, Inc.
+#                         Ben Finney <ben+python@benfinney.id.au>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Mercurial (hg) backend.
+"""
+
+import os
+import re
+import sys
+import unittest
+import doctest
+
+import vcs
+
+
+def new():
+    return Hg()
+
+class Hg(vcs.VCS):
+    name="hg"
+    client="hg"
+    versioned=True
+    def _vcs_help(self):
+        status,output,error = self._u_invoke_client("--help")
+        return output
+    def _vcs_detect(self, path):
+        """Detect whether a directory is revision-controlled using Mercurial"""
+        if self._u_search_parent_directories(path, ".hg") != None:
+            return True
+        return False
+    def _vcs_root(self, path):
+        status,output,error = self._u_invoke_client("root", directory=path)
+        return output.rstrip('\n')
+    def _vcs_init(self, path):
+        self._u_invoke_client("init", directory=path)
+    def _vcs_get_user_id(self):
+        status,output,error = self._u_invoke_client("showconfig","ui.username")
+        return output.rstrip('\n')
+    def _vcs_set_user_id(self, value):
+        """
+        Supported by the Config Extension, but that is not part of
+        standard Mercurial.
+        http://www.selenic.com/mercurial/wiki/index.cgi/ConfigExtension
+        """
+        raise vcs.SettingIDnotSupported
+    def _vcs_add(self, path):
+        self._u_invoke_client("add", path)
+    def _vcs_remove(self, path):
+        self._u_invoke_client("rm", "--force", path)
+    def _vcs_update(self, path):
+        pass
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
+        if revision == None:
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
+        else:
+            status,output,error = \
+                self._u_invoke_client("cat","-r",revision,path)
+            return output
+    def _vcs_duplicate_repo(self, directory, revision=None):
+        if revision == None:
+            return vcs.VCS._vcs_duplicate_repo(self, directory, revision)
+        else:
+            self._u_invoke_client("archive", "--rev", revision, directory)
+    def _vcs_commit(self, commitfile, allow_empty=False):
+        args = ['commit', '--logfile', commitfile]
+        status,output,error = self._u_invoke_client(*args)
+        if allow_empty == False:
+            strings = ["nothing changed"]
+            if self._u_any_in_string(strings, output) == True:
+                raise vcs.EmptyCommit()
+        return self._vcs_revision_id(-1)
+    def _vcs_revision_id(self, index, style="id"):
+        args = ["identify", "--rev", str(int(index)), "--%s" % style]
+        kwargs = {"expect": (0,255)}
+        status,output,error = self._u_invoke_client(*args, **kwargs)
+        if status == 0:
+            id = output.strip()
+            if id == '000000000000':
+                return None # before initial commit.
+            return id
+        return None
+
+\f    
+vcs.make_vcs_testcase_subclasses(Hg, sys.modules[__name__])
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/mapfile.py b/interfaces/email/interactive/libbe/mapfile.py
new file mode 100644 (file)
index 0000000..4d69601
--- /dev/null
@@ -0,0 +1,116 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Provide a means of saving and loading dictionaries of parameters.  The
+saved "mapfiles" should be clear, flat-text files, and allow easy merging of
+independent/conflicting changes.
+"""
+
+import errno
+import os.path
+import yaml
+import doctest
+
+
+class IllegalKey(Exception):
+    def __init__(self, key):
+        Exception.__init__(self, 'Illegal key "%s"' % key)
+        self.key = key
+
+class IllegalValue(Exception):
+    def __init__(self, value):
+        Exception.__init__(self, 'Illegal value "%s"' % value)
+        self.value = value 
+
+def generate(map):
+    """Generate a YAML mapfile content string.
+    >>> generate({"q":"p"})
+    'q: p\\n\\n'
+    >>> generate({"q":u"Fran\u00e7ais"})
+    'q: Fran\\xc3\\xa7ais\\n\\n'
+    >>> generate({"q":u"hello"})
+    'q: hello\\n\\n'
+    >>> generate({"q=":"p"})
+    Traceback (most recent call last):
+    IllegalKey: Illegal key "q="
+    >>> generate({"q:":"p"})
+    Traceback (most recent call last):
+    IllegalKey: Illegal key "q:"
+    >>> generate({"q\\n":"p"})
+    Traceback (most recent call last):
+    IllegalKey: Illegal key "q\\n"
+    >>> generate({"":"p"})
+    Traceback (most recent call last):
+    IllegalKey: Illegal key ""
+    >>> generate({">q":"p"})
+    Traceback (most recent call last):
+    IllegalKey: Illegal key ">q"
+    >>> generate({"q":"p\\n"})
+    Traceback (most recent call last):
+    IllegalValue: Illegal value "p\\n"
+    """
+    keys = map.keys()
+    keys.sort()
+    for key in keys:
+        try:
+            assert not key.startswith('>')
+            assert('\n' not in key)
+            assert('=' not in key)
+            assert(':' not in key)
+            assert(len(key) > 0)
+        except AssertionError:
+            raise IllegalKey(unicode(key).encode('unicode_escape'))
+        if "\n" in map[key]:
+            raise IllegalValue(unicode(map[key]).encode('unicode_escape'))
+
+    lines = []
+    for key in keys:
+        lines.append(yaml.safe_dump({key: map[key]},
+                                    default_flow_style=False,
+                                    allow_unicode=True))
+        lines.append("")
+    return '\n'.join(lines)
+
+def parse(contents):
+    """
+    Parse a YAML mapfile string.
+    >>> parse('q: p\\n\\n')['q']
+    'p'
+    >>> parse('q: \\'p\\'\\n\\n')['q']
+    'p'
+    >>> contents = generate({"a":"b", "c":"d", "e":"f"})
+    >>> dict = parse(contents)
+    >>> dict["a"]
+    'b'
+    >>> dict["c"]
+    'd'
+    >>> dict["e"]
+    'f'
+    """
+    return yaml.load(contents) or {}
+
+def map_save(vcs, path, map, allow_no_vcs=False):
+    """Save the map as a mapfile to the specified path"""
+    contents = generate(map)
+    vcs.set_file_contents(path, contents, allow_no_vcs)
+
+def map_load(vcs, path, allow_no_vcs=False):
+    contents = vcs.get_file_contents(path, allow_no_vcs=allow_no_vcs)
+    return parse(contents)
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/plugin.py b/interfaces/email/interactive/libbe/plugin.py
new file mode 100644 (file)
index 0000000..d593d69
--- /dev/null
@@ -0,0 +1,77 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Marien Zwart <marienz@gentoo.org>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Allow simple listing and loading of the various becommands and libbe
+submodules (i.e. "plugins").
+"""
+
+import os
+import os.path
+import sys
+import doctest
+
+def my_import(mod_name):
+    module = __import__(mod_name)
+    components = mod_name.split('.')
+    for comp in components[1:]:
+        module = getattr(module, comp)
+    return module
+
+def iter_plugins(prefix):
+    """
+    >>> "list" in [n for n,m in iter_plugins("becommands")]
+    True
+    >>> "plugin" in [n for n,m in iter_plugins("libbe")]
+    True
+    """
+    modfiles = os.listdir(os.path.join(plugin_path, prefix))
+    modfiles.sort()
+    for modfile in modfiles:
+        if modfile.startswith('.'):
+            continue # the occasional emacs temporary file
+        if modfile.endswith(".py") and modfile != "__init__.py":
+            yield modfile[:-3], my_import(prefix+"."+modfile[:-3])
+
+
+def get_plugin(prefix, name):
+    """
+    >>> get_plugin("becommands", "asdf") is None
+    True
+    >>> q = repr(get_plugin("becommands", "list"))
+    >>> q.startswith("<module 'becommands.list' from ")
+    True
+    """
+    dirprefix = os.path.join(*prefix.split('.'))
+    command_path = os.path.join(plugin_path, dirprefix, name+".py")
+    if os.path.isfile(command_path):
+        return my_import(prefix + "." + name)
+    return None
+
+plugin_path = os.path.realpath(os.path.dirname(os.path.dirname(__file__)))
+if plugin_path not in sys.path:
+    sys.path.append(plugin_path)
+
+suite = doctest.DocTestSuite()
+
+def _test():
+    import doctest
+    doctest.testmod()
+
+if __name__ == "__main__":
+    _test()
diff --git a/interfaces/email/interactive/libbe/properties.py b/interfaces/email/interactive/libbe/properties.py
new file mode 100644 (file)
index 0000000..09dd20e
--- /dev/null
@@ -0,0 +1,638 @@
+# Bugs Everywhere - a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+This module provides a series of useful decorators for defining
+various types of properties.  For example usage, consider the
+unittests at the end of the module.
+
+See
+  http://www.python.org/dev/peps/pep-0318/
+and
+  http://www.phyast.pitt.edu/~micheles/python/documentation.html
+for more information on decorators.
+"""
+
+import copy
+import types
+import unittest
+
+
+class ValueCheckError (ValueError):
+    def __init__(self, name, value, allowed):
+        action = "in" # some list of allowed values
+        if type(allowed) == types.FunctionType:
+            action = "allowed by" # some allowed-value check function
+        msg = "%s not %s %s for %s" % (value, action, allowed, name)
+        ValueError.__init__(self, msg)
+        self.name = name
+        self.value = value
+        self.allowed = allowed
+
+def Property(funcs):
+    """
+    End a chain of property decorators, returning a property.
+    """
+    args = {}
+    args["fget"] = funcs.get("fget", None)
+    args["fset"] = funcs.get("fset", None)
+    args["fdel"] = funcs.get("fdel", None)
+    args["doc"] = funcs.get("doc", None)
+
+    #print "Creating a property with"
+    #for key, val in args.items(): print key, value
+    return property(**args)
+
+def doc_property(doc=None):
+    """
+    Add a docstring to a chain of property decorators.
+    """
+    def decorator(funcs=None):
+        """
+        Takes either a dict of funcs {"fget":fnX, "fset":fnY, ...}
+        or a function fn() returning such a dict.
+        """
+        if hasattr(funcs, "__call__"):
+            funcs = funcs() # convert from function-arg to dict
+        funcs["doc"] = doc
+        return funcs
+    return decorator
+
+def local_property(name, null=None, mutable_null=False):
+    """
+    Define get/set access to per-parent-instance local storage.  Uses
+    ._<name>_value to store the value for a particular owner instance.
+    If the ._<name>_value attribute does not exist, returns null.
+
+    If mutable_null == True, we only release deepcopies of the null to
+    the outside world.
+    """
+    def decorator(funcs):
+        if hasattr(funcs, "__call__"):
+            funcs = funcs()
+        fget = funcs.get("fget", None)
+        fset = funcs.get("fset", None)
+        def _fget(self):
+            if fget is not None:
+                fget(self)
+            if mutable_null == True:
+                ret_null = copy.deepcopy(null)
+            else:
+                ret_null = null
+            value = getattr(self, "_%s_value" % name, ret_null)
+            return value
+        def _fset(self, value):
+            setattr(self, "_%s_value" % name, value)
+            if fset is not None:
+                fset(self, value)
+        funcs["fget"] = _fget
+        funcs["fset"] = _fset
+        funcs["name"] = name
+        return funcs
+    return decorator
+
+def settings_property(name, null=None):
+    """
+    Similar to local_property, except where local_property stores the
+    value in instance._<name>_value, settings_property stores the
+    value in instance.settings[name].
+    """
+    def decorator(funcs):
+        if hasattr(funcs, "__call__"):
+            funcs = funcs()
+        fget = funcs.get("fget", None)
+        fset = funcs.get("fset", None)
+        def _fget(self):
+            if fget is not None:
+                fget(self)
+            value = self.settings.get(name, null)
+            return value
+        def _fset(self, value):
+            self.settings[name] = value
+            if fset is not None:
+                fset(self, value)
+        funcs["fget"] = _fget
+        funcs["fset"] = _fset
+        funcs["name"] = name
+        return funcs
+    return decorator
+
+
+# Allow comparison and caching with _original_ values for mutables,
+# since
+#
+# >>> a = []
+# >>> b = a
+# >>> b.append(1)
+# >>> a
+# [1]
+# >>> a==b
+# True
+def _hash_mutable_value(value):
+    return repr(value)
+def _init_mutable_property_cache(self):
+    if not hasattr(self, "_mutable_property_cache_hash"):
+        # first call to _fget for any mutable property
+        self._mutable_property_cache_hash = {}
+        self._mutable_property_cache_copy = {}
+def _set_cached_mutable_property(self, cacher_name, property_name, value):
+    _init_mutable_property_cache(self)
+    self._mutable_property_cache_hash[(cacher_name, property_name)] = \
+        _hash_mutable_value(value)
+    self._mutable_property_cache_copy[(cacher_name, property_name)] = \
+        copy.deepcopy(value)
+def _get_cached_mutable_property(self, cacher_name, property_name, default=None):
+    _init_mutable_property_cache(self)
+    if (cacher_name, property_name) not in self._mutable_property_cache_copy:
+        return default
+    return self._mutable_property_cache_copy[(cacher_name, property_name)]
+def _cmp_cached_mutable_property(self, cacher_name, property_name, value, default=None):
+    _init_mutable_property_cache(self)
+    if (cacher_name, property_name) not in self._mutable_property_cache_hash:
+        _set_cached_mutable_property(self, cacher_name, property_name, default)
+    old_hash = self._mutable_property_cache_hash[(cacher_name, property_name)]
+    return cmp(_hash_mutable_value(value), old_hash)
+
+
+def defaulting_property(default=None, null=None,
+                        mutable_default=False):
+    """
+    Define a default value for get access to a property.
+    If the stored value is null, then default is returned.
+
+    If mutable_default == True, we only release deepcopies of the
+    default to the outside world.
+
+    null should never escape to the outside world, so don't worry
+    about it being a mutable.
+    """
+    def decorator(funcs):
+        if hasattr(funcs, "__call__"):
+            funcs = funcs()
+        fget = funcs.get("fget")
+        fset = funcs.get("fset")
+        name = funcs.get("name", "<unknown>")
+        def _fget(self):
+            value = fget(self)
+            if value == null:
+                if mutable_default == True:
+                    return copy.deepcopy(default)
+                else:
+                    return default
+            return value
+        def _fset(self, value):
+            if value == default:
+                value = null
+            fset(self, value)
+        funcs["fget"] = _fget
+        funcs["fset"] = _fset
+        return funcs
+    return decorator
+
+def fn_checked_property(value_allowed_fn):
+    """
+    Define allowed values for get/set access to a property.
+    """
+    def decorator(funcs):
+        if hasattr(funcs, "__call__"):
+            funcs = funcs()
+        fget = funcs.get("fget")
+        fset = funcs.get("fset")
+        name = funcs.get("name", "<unknown>")
+        def _fget(self):
+            value = fget(self)
+            if value_allowed_fn(value) != True:
+                raise ValueCheckError(name, value, value_allowed_fn)
+            return value
+        def _fset(self, value):
+            if value_allowed_fn(value) != True:
+                raise ValueCheckError(name, value, value_allowed_fn)
+            fset(self, value)
+        funcs["fget"] = _fget
+        funcs["fset"] = _fset
+        return funcs
+    return decorator
+
+def checked_property(allowed=[]):
+    """
+    Define allowed values for get/set access to a property.
+    """
+    def decorator(funcs):
+        if hasattr(funcs, "__call__"):
+            funcs = funcs()
+        fget = funcs.get("fget")
+        fset = funcs.get("fset")
+        name = funcs.get("name", "<unknown>")
+        def _fget(self):
+            value = fget(self)
+            if value not in allowed:
+                raise ValueCheckError(name, value, allowed)
+            return value
+        def _fset(self, value):
+            if value not in allowed:
+                raise ValueCheckError(name, value, allowed)
+            fset(self, value)
+        funcs["fget"] = _fget
+        funcs["fset"] = _fset
+        return funcs
+    return decorator
+
+def cached_property(generator, initVal=None, mutable=False):
+    """
+    Allow caching of values generated by generator(instance), where
+    instance is the instance to which this property belongs.  Uses
+    ._<name>_cache to store a cache flag for a particular owner
+    instance.
+
+    When the cache flag is True or missing and the stored value is
+    initVal, the first fget call triggers the generator function,
+    whose output is stored in _<name>_cached_value.  That and
+    subsequent calls to fget will return this cached value.
+
+    If the input value is no longer initVal (e.g. a value has been
+    loaded from disk or set with fset), that value overrides any
+    cached value, and this property has no effect.
+
+    When the cache flag is False and the stored value is initVal, the
+    generator is not cached, but is called on every fget.
+
+    The cache flag is missing on initialization.  Particular instances
+    may override by setting their own flag.
+
+    In the case that mutable == True, all caching is disabled and the
+    generator is called whenever the cached value would otherwise be
+    used.
+    """
+    def decorator(funcs):
+        if hasattr(funcs, "__call__"):
+            funcs = funcs()
+        fget = funcs.get("fget")
+        name = funcs.get("name", "<unknown>")
+        def _fget(self):
+            cache = getattr(self, "_%s_cache" % name, True)
+            value = fget(self)
+            if value == initVal:
+                if cache == True and mutable == False:
+                    if hasattr(self, "_%s_cached_value" % name):
+                        value = getattr(self, "_%s_cached_value" % name)
+                    else:
+                        value = generator(self)
+                        setattr(self, "_%s_cached_value" % name, value)
+                else:
+                    value = generator(self)
+            return value
+        funcs["fget"] = _fget
+        return funcs
+    return decorator
+
+def primed_property(primer, initVal=None):
+    """
+    Just like a cached_property, except that instead of returning a
+    new value and running fset to cache it, the primer performs some
+    background manipulation (e.g. loads data into instance.settings)
+    such that a _second_ pass through fget succeeds.
+
+    The 'cache' flag becomes a 'prime' flag, with priming taking place
+    whenever ._<name>_prime is True, or is False or missing and
+    value == initVal.
+    """
+    def decorator(funcs):
+        if hasattr(funcs, "__call__"):
+            funcs = funcs()
+        fget = funcs.get("fget")
+        name = funcs.get("name", "<unknown>")
+        def _fget(self):
+            prime = getattr(self, "_%s_prime" % name, False)
+            if prime == False:
+                value = fget(self)
+            if prime == True or (prime == False and value == initVal):
+                primer(self)
+                value = fget(self)
+            return value
+        funcs["fget"] = _fget
+        return funcs
+    return decorator
+
+def change_hook_property(hook, mutable=False, default=None):
+    """
+    Call the function hook(instance, old_value, new_value) whenever a
+    value different from the current value is set (instance is a a
+    reference to the class instance to which this property belongs).
+    This is useful for saving changes to disk, etc.  This function is
+    called _after_ the new value has been stored, allowing you to
+    change the stored value if you want.
+
+    In the case of mutables, things are slightly trickier.  Because
+    the property-owning class has no way of knowing when the value
+    changes.  We work around this by caching a private deepcopy of the
+    mutable value, and checking for changes whenever the property is
+    set (obviously) or retrieved (to check for external changes).  So
+    long as you're conscientious about accessing the property after
+    making external modifications, mutability woln't be a problem.
+      t.x.append(5) # external modification
+      t.x           # dummy access notices change and triggers hook
+    See testChangeHookMutableProperty for an example of the expected
+    behavior.
+    """
+    def decorator(funcs):
+        if hasattr(funcs, "__call__"):
+            funcs = funcs()
+        fget = funcs.get("fget")
+        fset = funcs.get("fset")
+        name = funcs.get("name", "<unknown>")
+        def _fget(self, new_value=None, from_fset=False): # only used if mutable == True
+            if from_fset == True:
+                value = new_value # compare new value with cached
+            else:
+                value = fget(self) # compare current value with cached
+            if _cmp_cached_mutable_property(self, "change hook property", name, value, default) != 0:
+                # there has been a change, cache new value
+                old_value = _get_cached_mutable_property(self, "change hook property", name, default)
+                _set_cached_mutable_property(self, "change hook property", name, value)
+                if from_fset == True: # return previously cached value
+                    value = old_value
+                else: # the value changed while we weren't looking
+                    hook(self, old_value, value)
+            return value
+        def _fset(self, value):
+            if mutable == True: # get cached previous value
+                old_value = _fget(self, new_value=value, from_fset=True)
+            else:
+                old_value = fget(self)
+            fset(self, value)
+            if value != old_value:
+                hook(self, old_value, value)
+        if mutable == True:
+            funcs["fget"] = _fget
+        funcs["fset"] = _fset
+        return funcs
+    return decorator
+
+
+class DecoratorTests(unittest.TestCase):
+    def testLocalDoc(self):
+        class Test(object):
+            @Property
+            @doc_property("A fancy property")
+            def x():
+                return {}
+        self.failUnless(Test.x.__doc__ == "A fancy property",
+                        Test.x.__doc__)
+    def testLocalProperty(self):
+        class Test(object):
+            @Property
+            @local_property(name="LOCAL")
+            def x():
+                return {}
+        t = Test()
+        self.failUnless(t.x == None, str(t.x))
+        t.x = 'z' # the first set initializes ._LOCAL_value
+        self.failUnless(t.x == 'z', str(t.x))
+        self.failUnless("_LOCAL_value" in dir(t), dir(t))
+        self.failUnless(t._LOCAL_value == 'z', t._LOCAL_value)
+    def testSettingsProperty(self):
+        class Test(object):
+            @Property
+            @settings_property(name="attr")
+            def x():
+                return {}
+            def __init__(self):
+                self.settings = {}
+        t = Test()
+        self.failUnless(t.x == None, str(t.x))
+        t.x = 'z' # the first set initializes ._LOCAL_value
+        self.failUnless(t.x == 'z', str(t.x))
+        self.failUnless("attr" in t.settings, t.settings)
+        self.failUnless(t.settings["attr"] == 'z', t.settings["attr"])
+    def testDefaultingLocalProperty(self):
+        class Test(object):
+            @Property
+            @defaulting_property(default='y', null='x')
+            @local_property(name="DEFAULT", null=5)
+            def x(): return {}
+        t = Test()
+        self.failUnless(t.x == 5, str(t.x))
+        t.x = 'x'
+        self.failUnless(t.x == 'y', str(t.x))
+        t.x = 'y'
+        self.failUnless(t.x == 'y', str(t.x))
+        t.x = 'z'
+        self.failUnless(t.x == 'z', str(t.x))
+        t.x = 5
+        self.failUnless(t.x == 5, str(t.x))
+    def testCheckedLocalProperty(self):
+        class Test(object):
+            @Property
+            @checked_property(allowed=['x', 'y', 'z'])
+            @local_property(name="CHECKED")
+            def x(): return {}
+            def __init__(self):
+                self._CHECKED_value = 'x'
+        t = Test()
+        self.failUnless(t.x == 'x', str(t.x))
+        try:
+            t.x = None
+            e = None
+        except ValueCheckError, e:
+            pass
+        self.failUnless(type(e) == ValueCheckError, type(e))
+    def testTwoCheckedLocalProperties(self):
+        class Test(object):
+            @Property
+            @checked_property(allowed=['x', 'y', 'z'])
+            @local_property(name="X")
+            def x(): return {}
+
+            @Property
+            @checked_property(allowed=['a', 'b', 'c'])
+            @local_property(name="A")
+            def a(): return {}
+            def __init__(self):
+                self._A_value = 'a'
+                self._X_value = 'x'
+        t = Test()
+        try:
+            t.x = 'a'
+            e = None
+        except ValueCheckError, e:
+            pass
+        self.failUnless(type(e) == ValueCheckError, type(e))
+        t.x = 'x'
+        t.x = 'y'
+        t.x = 'z'
+        try:
+            t.a = 'x'
+            e = None
+        except ValueCheckError, e:
+            pass
+        self.failUnless(type(e) == ValueCheckError, type(e))
+        t.a = 'a'
+        t.a = 'b'
+        t.a = 'c'
+    def testFnCheckedLocalProperty(self):
+        class Test(object):
+            @Property
+            @fn_checked_property(lambda v : v in ['x', 'y', 'z'])
+            @local_property(name="CHECKED")
+            def x(): return {}
+            def __init__(self):
+                self._CHECKED_value = 'x'
+        t = Test()
+        self.failUnless(t.x == 'x', str(t.x))
+        try:
+            t.x = None
+            e = None
+        except ValueCheckError, e:
+            pass
+        self.failUnless(type(e) == ValueCheckError, type(e))
+    def testCachedLocalProperty(self):
+        class Gen(object):
+            def __init__(self):
+                self.i = 0
+            def __call__(self, owner):
+                self.i += 1
+                return self.i
+        class Test(object):
+            @Property
+            @cached_property(generator=Gen(), initVal=None)
+            @local_property(name="CACHED")
+            def x(): return {}
+        t = Test()
+        self.failIf("_CACHED_cache" in dir(t), getattr(t, "_CACHED_cache", None))
+        self.failUnless(t.x == 1, t.x)
+        self.failUnless(t.x == 1, t.x)
+        self.failUnless(t.x == 1, t.x)
+        t.x = 8
+        self.failUnless(t.x == 8, t.x)
+        self.failUnless(t.x == 8, t.x)
+        t._CACHED_cache = False        # Caching is off, but the stored value
+        val = t.x                      # is 8, not the initVal (None), so we
+        self.failUnless(val == 8, val) # get 8.
+        t._CACHED_value = None         # Now we've set the stored value to None
+        val = t.x                      # so future calls to fget (like this)
+        self.failUnless(val == 2, val) # will call the generator every time...
+        val = t.x
+        self.failUnless(val == 3, val)
+        val = t.x
+        self.failUnless(val == 4, val)
+        t._CACHED_cache = True              # We turn caching back on, and get
+        self.failUnless(t.x == 1, str(t.x)) # the original cached value.
+        del t._CACHED_cached_value          # Removing that value forces a
+        self.failUnless(t.x == 5, str(t.x)) # single cache-regenerating call
+        self.failUnless(t.x == 5, str(t.x)) # to the genenerator, after which
+        self.failUnless(t.x == 5, str(t.x)) # we get the new cached value.
+    def testPrimedLocalProperty(self):
+        class Test(object):
+            def prime(self):
+                self.settings["PRIMED"] = "initialized"
+            @Property
+            @primed_property(primer=prime, initVal=None)
+            @settings_property(name="PRIMED")
+            def x(): return {}
+            def __init__(self):
+                self.settings={}
+        t = Test()
+        self.failIf("_PRIMED_prime" in dir(t), getattr(t, "_PRIMED_prime", None))
+        self.failUnless(t.x == "initialized", t.x)
+        t.x = 1
+        self.failUnless(t.x == 1, t.x)
+        t.x = None
+        self.failUnless(t.x == "initialized", t.x)
+        t._PRIMED_prime = True
+        t.x = 3
+        self.failUnless(t.x == "initialized", t.x)
+        t._PRIMED_prime = False
+        t.x = 3
+        self.failUnless(t.x == 3, t.x)
+    def testChangeHookLocalProperty(self):
+        class Test(object):
+            def _hook(self, old, new):
+                self.old = old
+                self.new = new
+
+            @Property
+            @change_hook_property(_hook)
+            @local_property(name="HOOKED")
+            def x(): return {}
+        t = Test()
+        t.x = 1
+        self.failUnless(t.old == None, t.old)
+        self.failUnless(t.new == 1, t.new)
+        t.x = 1
+        self.failUnless(t.old == None, t.old)
+        self.failUnless(t.new == 1, t.new)
+        t.x = 2
+        self.failUnless(t.old == 1, t.old)
+        self.failUnless(t.new == 2, t.new)
+    def testChangeHookMutableProperty(self):
+        class Test(object):
+            def _hook(self, old, new):
+                self.old = old
+                self.new = new
+                self.hook_calls += 1
+
+            @Property
+            @change_hook_property(_hook, mutable=True)
+            @local_property(name="HOOKED")
+            def x(): return {}
+        t = Test()
+        t.hook_calls = 0
+        t.x = []
+        self.failUnless(t.old == None, t.old)
+        self.failUnless(t.new == [], t.new)
+        self.failUnless(t.hook_calls == 1, t.hook_calls)
+        a = t.x
+        a.append(5)
+        t.x = a
+        self.failUnless(t.old == [], t.old)
+        self.failUnless(t.new == [5], t.new)
+        self.failUnless(t.hook_calls == 2, t.hook_calls)
+        t.x = []
+        self.failUnless(t.old == [5], t.old)
+        self.failUnless(t.new == [], t.new)
+        self.failUnless(t.hook_calls == 3, t.hook_calls)
+        # now append without reassigning.  this doesn't trigger the
+        # change, since we don't ever set t.x, only get it and mess
+        # with it.  It does, however, update our t.new, since t.new =
+        # t.x and is not a static copy.
+        t.x.append(5)
+        self.failUnless(t.old == [5], t.old)
+        self.failUnless(t.new == [5], t.new)
+        self.failUnless(t.hook_calls == 3, t.hook_calls)
+        # however, the next t.x get _will_ notice the change...
+        a = t.x
+        self.failUnless(t.old == [], t.old)
+        self.failUnless(t.new == [5], t.new)
+        self.failUnless(t.hook_calls == 4, t.hook_calls)
+        t.x.append(6) # this append(6) is not noticed yet
+        self.failUnless(t.old == [], t.old)
+        self.failUnless(t.new == [5,6], t.new)
+        self.failUnless(t.hook_calls == 4, t.hook_calls)
+        # this append(7) is not noticed, but the t.x get causes the
+        # append(6) to be noticed
+        t.x.append(7)
+        self.failUnless(t.old == [5], t.old)
+        self.failUnless(t.new == [5,6,7], t.new)
+        self.failUnless(t.hook_calls == 5, t.hook_calls)
+        a = t.x # now the append(7) is noticed
+        self.failUnless(t.old == [5,6], t.old)
+        self.failUnless(t.new == [5,6,7], t.new)
+        self.failUnless(t.hook_calls == 6, t.hook_calls)
+
+
+suite = unittest.TestLoader().loadTestsFromTestCase(DecoratorTests)
+
diff --git a/interfaces/email/interactive/libbe/settings_object.py b/interfaces/email/interactive/libbe/settings_object.py
new file mode 100644 (file)
index 0000000..ceea9d5
--- /dev/null
@@ -0,0 +1,412 @@
+# Bugs Everywhere - a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+This module provides a base class implementing settings-dict based
+property storage useful for BE objects with saved properties
+(e.g. BugDir, Bug, Comment).  For example usage, consider the
+unittests at the end of the module.
+"""
+
+import doctest
+import unittest
+
+from properties import Property, doc_property, local_property, \
+    defaulting_property, checked_property, fn_checked_property, \
+    cached_property, primed_property, change_hook_property, \
+    settings_property
+
+
+class _Token (object):
+    """
+    `Control' value class for properties.  We want values that only
+    mean something to the settings_object module.
+    """
+    pass
+
+class UNPRIMED (_Token):
+    "Property has not been primed."
+    pass
+
+class EMPTY (_Token):
+    """
+    Property has been primed but has no user-set value, so use
+    default/generator value.
+    """
+    pass
+
+
+def prop_save_settings(self, old, new):
+    """
+    The default action undertaken when a property changes.
+    """
+    if self.sync_with_disk==True:
+        self.save_settings()
+
+def prop_load_settings(self):
+    """
+    The default action undertaken when an UNPRIMED property is accessed.
+    """
+    if self.sync_with_disk==True and self._settings_loaded==False:
+        self.load_settings()
+    else:
+        self._setup_saved_settings(flag_as_loaded=False)
+
+# Some name-mangling routines for pretty printing setting names
+def setting_name_to_attr_name(self, name):
+    """
+    Convert keys to the .settings dict into their associated
+    SavedSettingsObject attribute names.
+    >>> print setting_name_to_attr_name(None,"User-id")
+    user_id
+    """
+    return name.lower().replace('-', '_')
+
+def attr_name_to_setting_name(self, name):
+    """
+    The inverse of setting_name_to_attr_name.
+    >>> print attr_name_to_setting_name(None, "user_id")
+    User-id
+    """
+    return name.capitalize().replace('_', '-')
+
+
+def versioned_property(name, doc,
+                       default=None, generator=None,
+                       change_hook=prop_save_settings,
+                       mutable=False,
+                       primer=prop_load_settings,
+                       allowed=None, check_fn=None,
+                       settings_properties=[],
+                       required_saved_properties=[],
+                       require_save=False):
+    """
+    Combine the common decorators in a single function.
+
+    Use zero or one (but not both) of default or generator, since a
+    working default will keep the generator from functioning.  Use the
+    default if you know what you want the default value to be at
+    'coding time'.  Use the generator if you can write a function to
+    determine a valid default at run time.  If both default and
+    generator are None, then the property will be a defaulting
+    property which defaults to None.
+
+    allowed and check_fn have a similar relationship, although you can
+    use both of these if you want.  allowed compares the proposed
+    value against a list determined at 'coding time' and check_fn
+    allows more flexible comparisons to take place at run time.
+
+    Set require_save to True if you want to save the default/generated
+    value for a property, to protect against future changes.  E.g., we
+    currently expect all comments to be 'text/plain' but in the future
+    we may want to default to 'text/html'.  If we don't want the old
+    comments to be interpreted as 'text/html', we would require that
+    the content type be saved.
+
+    change_hook, primer, settings_properties, and
+    required_saved_properties are only options to get their defaults
+    into our local scope.  Don't mess with them.
+
+    Set mutable=True if:
+      * default is a mutable
+      * your generator function may return mutables
+      * you set change_hook and might have mutable property values
+    See the docstrings in libbe.properties for details on how each of
+    these cases are handled.
+    """
+    settings_properties.append(name)
+    if require_save == True:
+        required_saved_properties.append(name)
+    def decorator(funcs):
+        fulldoc = doc
+        if default != None or generator == None:
+            defaulting  = defaulting_property(default=default, null=EMPTY,
+                                              mutable_default=mutable)
+            fulldoc += "\n\nThis property defaults to %s." % default
+        if generator != None:
+            cached = cached_property(generator=generator, initVal=EMPTY,
+                                     mutable=mutable)
+            fulldoc += "\n\nThis property is generated with %s." % generator
+        if check_fn != None:
+            fn_checked = fn_checked_property(value_allowed_fn=check_fn)
+            fulldoc += "\n\nThis property is checked with %s." % check_fn
+        if allowed != None:
+            checked = checked_property(allowed=allowed)
+            fulldoc += "\n\nThe allowed values for this property are: %s." \
+                       % (', '.join(allowed))
+        hooked      = change_hook_property(hook=change_hook, mutable=mutable,
+                                           default=EMPTY)
+        primed      = primed_property(primer=primer, initVal=UNPRIMED)
+        settings    = settings_property(name=name, null=UNPRIMED)
+        docp        = doc_property(doc=fulldoc)
+        deco = hooked(primed(settings(docp(funcs))))
+        if default != None or generator == None:
+            deco = defaulting(deco)
+        if generator != None:
+            deco = cached(deco)
+        if check_fn != None:
+            deco = fn_checked(deco)
+        if allowed != None:
+            deco = checked(deco)
+        return Property(deco)
+    return decorator
+
+class SavedSettingsObject(object):
+
+    # Keep a list of properties that may be stored in the .settings dict.
+    #settings_properties = []
+
+    # A list of properties that we save to disk, even if they were
+    # never set (in which case we save the default value).  This
+    # protects against future changes in default values.
+    #required_saved_properties = []
+
+    _setting_name_to_attr_name = setting_name_to_attr_name
+    _attr_name_to_setting_name = attr_name_to_setting_name
+
+    def __init__(self):
+        self._settings_loaded = False
+        self.sync_with_disk = False
+        self.settings = {}
+
+    def load_settings(self):
+        """Load the settings from disk."""
+        # Override.  Must call ._setup_saved_settings() after loading.
+        self.settings = {}
+        self._setup_saved_settings()
+
+    def _setup_saved_settings(self, flag_as_loaded=True):
+        """
+        To be run after setting self.settings up from disk.  Marks all
+        settings as primed.
+        """
+        for property in self.settings_properties:
+            if property not in self.settings:
+                self.settings[property] = EMPTY
+            elif self.settings[property] == UNPRIMED:
+                self.settings[property] = EMPTY
+        if flag_as_loaded == True:
+            self._settings_loaded = True
+
+    def save_settings(self):
+        """Load the settings from disk."""
+        # Override.  Should save the dict output of ._get_saved_settings()
+        settings = self._get_saved_settings()
+        pass # write settings to disk....
+
+    def _get_saved_settings(self):
+        settings = {}
+        for k,v in self.settings.items():
+            if v != None and v != EMPTY:
+                settings[k] = v
+        for k in self.required_saved_properties:
+            settings[k] = getattr(self, self._setting_name_to_attr_name(k))
+        return settings
+
+    def clear_cached_setting(self, setting=None):
+        "If setting=None, clear *all* cached settings"
+        if setting != None:
+            if hasattr(self, "_%s_cached_value" % setting):
+                delattr(self, "_%s_cached_value" % setting)
+        else:
+            for setting in settings_properties:
+                self.clear_cached_setting(setting)
+
+
+class SavedSettingsObjectTests(unittest.TestCase):
+    def testSimpleProperty(self):
+        """Testing a minimal versioned property"""
+        class Test(SavedSettingsObject):
+            settings_properties = []
+            required_saved_properties = []
+            @versioned_property(name="Content-type",
+                                doc="A test property",
+                                settings_properties=settings_properties,
+                                required_saved_properties=required_saved_properties)
+            def content_type(): return {}
+            def __init__(self):
+                SavedSettingsObject.__init__(self)
+        t = Test()
+        # access missing setting
+        self.failUnless(t._settings_loaded == False, t._settings_loaded)
+        self.failUnless(len(t.settings) == 0, len(t.settings))
+        self.failUnless(t.content_type == None, t.content_type)
+        # accessing t.content_type triggers the priming, which runs
+        # t._setup_saved_settings, which fills out t.settings with
+        # EMPTY data.  t._settings_loaded is still false though, since
+        # the default priming does not do any of the `official' loading
+        # that occurs in t.load_settings.
+        self.failUnless(len(t.settings) == 1, len(t.settings))
+        self.failUnless(t.settings["Content-type"] == EMPTY,
+                        t.settings["Content-type"])
+        self.failUnless(t._settings_loaded == False, t._settings_loaded)
+        # load settings creates an EMPTY value in the settings array
+        t.load_settings()
+        self.failUnless(t._settings_loaded == True, t._settings_loaded)
+        self.failUnless(t.settings["Content-type"] == EMPTY,
+                        t.settings["Content-type"])
+        self.failUnless(t.content_type == None, t.content_type)
+        self.failUnless(len(t.settings) == 1, len(t.settings))
+        self.failUnless(t.settings["Content-type"] == EMPTY,
+                        t.settings["Content-type"])
+        # now we set a value
+        t.content_type = 5
+        self.failUnless(t.settings["Content-type"] == 5,
+                        t.settings["Content-type"])
+        self.failUnless(t.content_type == 5, t.content_type)
+        self.failUnless(t.settings["Content-type"] == 5,
+                        t.settings["Content-type"])
+        # now we set another value
+        t.content_type = "text/plain"
+        self.failUnless(t.content_type == "text/plain", t.content_type)
+        self.failUnless(t.settings["Content-type"] == "text/plain",
+                        t.settings["Content-type"])
+        self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+                        t._get_saved_settings())
+        # now we clear to the post-primed value
+        t.content_type = EMPTY
+        self.failUnless(t._settings_loaded == True, t._settings_loaded)
+        self.failUnless(t.settings["Content-type"] == EMPTY,
+                        t.settings["Content-type"])
+        self.failUnless(t.content_type == None, t.content_type)
+        self.failUnless(len(t.settings) == 1, len(t.settings))
+        self.failUnless(t.settings["Content-type"] == EMPTY,
+                        t.settings["Content-type"])
+    def testDefaultingProperty(self):
+        """Testing a defaulting versioned property"""
+        class Test(SavedSettingsObject):
+            settings_properties = []
+            required_saved_properties = []
+            @versioned_property(name="Content-type",
+                                doc="A test property",
+                                default="text/plain",
+                                settings_properties=settings_properties,
+                                required_saved_properties=required_saved_properties)
+            def content_type(): return {}
+            def __init__(self):
+                SavedSettingsObject.__init__(self)
+        t = Test()
+        self.failUnless(t._settings_loaded == False, t._settings_loaded)
+        self.failUnless(t.content_type == "text/plain", t.content_type)
+        self.failUnless(t._settings_loaded == False, t._settings_loaded)
+        t.load_settings()
+        self.failUnless(t._settings_loaded == True, t._settings_loaded)
+        self.failUnless(t.content_type == "text/plain", t.content_type)
+        self.failUnless(t.settings["Content-type"] == EMPTY,
+                        t.settings["Content-type"])
+        self.failUnless(t._get_saved_settings() == {}, t._get_saved_settings())
+        t.content_type = "text/html"
+        self.failUnless(t.content_type == "text/html",
+                        t.content_type)
+        self.failUnless(t.settings["Content-type"] == "text/html",
+                        t.settings["Content-type"])
+        self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+                        t._get_saved_settings())
+    def testRequiredDefaultingProperty(self):
+        """Testing a required defaulting versioned property"""
+        class Test(SavedSettingsObject):
+            settings_properties = []
+            required_saved_properties = []
+            @versioned_property(name="Content-type",
+                                doc="A test property",
+                                default="text/plain",
+                                settings_properties=settings_properties,
+                                required_saved_properties=required_saved_properties,
+                                require_save=True)
+            def content_type(): return {}
+            def __init__(self):
+                SavedSettingsObject.__init__(self)
+        t = Test()
+        self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+                        t._get_saved_settings())
+        t.content_type = "text/html"
+        self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+                        t._get_saved_settings())
+    def testClassVersionedPropertyDefinition(self):
+        """Testing a class-specific _versioned property decorator"""
+        class Test(SavedSettingsObject):
+            settings_properties = []
+            required_saved_properties = []
+            def _versioned_property(settings_properties=settings_properties,
+                                    required_saved_properties=required_saved_properties,
+                                    **kwargs):
+                if "settings_properties" not in kwargs:
+                    kwargs["settings_properties"] = settings_properties
+                if "required_saved_properties" not in kwargs:
+                    kwargs["required_saved_properties"]=required_saved_properties
+                return versioned_property(**kwargs)
+            @_versioned_property(name="Content-type",
+                                doc="A test property",
+                                default="text/plain",
+                                require_save=True)
+            def content_type(): return {}
+            def __init__(self):
+                SavedSettingsObject.__init__(self)
+        t = Test()
+        self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
+                        t._get_saved_settings())
+        t.content_type = "text/html"
+        self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
+                        t._get_saved_settings())
+    def testMutableChangeHookedProperty(self):
+        """Testing a mutable change-hooked property"""
+        SAVES = []
+        def prop_log_save_settings(self, old, new, saves=SAVES):
+            saves.append("'%s' -> '%s'" % (str(old), str(new)))
+            prop_save_settings(self, old, new)
+        class Test(SavedSettingsObject):
+            settings_properties = []
+            required_saved_properties = []
+            @versioned_property(name="List-type",
+                                doc="A test property",
+                                mutable=True,
+                                change_hook=prop_log_save_settings,
+                                settings_properties=settings_properties,
+                                required_saved_properties=required_saved_properties)
+            def list_type(): return {}
+            def __init__(self):
+                SavedSettingsObject.__init__(self)
+        t = Test()
+        self.failUnless(t._settings_loaded == False, t._settings_loaded)
+        t.load_settings()
+        self.failUnless(SAVES == [], SAVES)
+        self.failUnless(t._settings_loaded == True, t._settings_loaded)
+        self.failUnless(t.list_type == None, t.list_type)
+        self.failUnless(SAVES == [], SAVES)
+        self.failUnless(t.settings["List-type"]==EMPTY,t.settings["List-type"])
+        t.list_type = []
+        self.failUnless(t.settings["List-type"] == [], t.settings["List-type"])
+        self.failUnless(SAVES == [
+                "'<class 'libbe.settings_object.EMPTY'>' -> '[]'"
+                ], SAVES)
+        t.list_type.append(5)
+        self.failUnless(SAVES == [
+                "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
+                ], SAVES)
+        self.failUnless(t.settings["List-type"] == [5],t.settings["List-type"])
+        self.failUnless(SAVES == [ # the append(5) has not yet been saved
+                "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
+                ], SAVES)
+        self.failUnless(t.list_type == [5], t.list_type) # <-get triggers saved
+
+        self.failUnless(SAVES == [ # now the append(5) has been saved.
+                "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
+                "'[]' -> '[5]'"
+                ], SAVES)
+
+unitsuite=unittest.TestLoader().loadTestsFromTestCase(SavedSettingsObjectTests)
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/tree.py b/interfaces/email/interactive/libbe/tree.py
new file mode 100644 (file)
index 0000000..06d09e5
--- /dev/null
@@ -0,0 +1,183 @@
+# Bugs Everywhere, a distributed bugtracker
+# Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define a traversable tree structure.
+"""
+
+import doctest
+
+class Tree(list):
+    """
+    Construct
+               +-b---d-g
+             a-+   +-e
+               +-c-+-f-h-i
+    with
+    >>> i = Tree();       i.n = "i"
+    >>> h = Tree([i]);    h.n = "h"
+    >>> f = Tree([h]);    f.n = "f"
+    >>> e = Tree();       e.n = "e"
+    >>> c = Tree([f,e]);  c.n = "c"
+    >>> g = Tree();       g.n = "g"
+    >>> d = Tree([g]);    d.n = "d"
+    >>> b = Tree([d]);    b.n = "b"
+    >>> a = Tree();       a.n = "a"
+    >>> a.append(c)
+    >>> a.append(b)
+
+    >>> a.branch_len()
+    5
+    >>> a.sort(key=lambda node : -node.branch_len())
+    >>> "".join([node.n for node in a.traverse()])
+    'acfhiebdg'
+    >>> a.sort(key=lambda node : node.branch_len())
+    >>> "".join([node.n for node in a.traverse()])
+    'abdgcefhi'
+    >>> "".join([node.n for node in a.traverse(depth_first=False)])
+    'abcdefghi'
+    >>> for depth,node in a.thread():
+    ...     print "%*s" % (2*depth+1, node.n)
+    a
+      b
+        d
+          g
+      c
+        e
+        f
+          h
+            i
+    >>> for depth,node in a.thread(flatten=True):
+    ...     print "%*s" % (2*depth+1, node.n)
+    a
+      b
+      d
+      g
+    c
+      e
+    f
+    h
+    i
+    >>> a.has_descendant(g)
+    True
+    >>> c.has_descendant(g)
+    False
+    >>> a.has_descendant(a)
+    False
+    >>> a.has_descendant(a, match_self=True)
+    True
+    """
+    def __eq__(self, other):
+        return id(self) == id(other)
+
+    def branch_len(self):
+        """
+        Exhaustive search every time == SLOW.
+
+        Use only on small trees, or reimplement by overriding
+        child-addition methods to allow accurate caching.
+
+        For the tree
+               +-b---d-g
+             a-+   +-e
+               +-c-+-f-h-i
+        this method returns 5.
+        """
+        if len(self) == 0:
+            return 1
+        else:
+            return 1 + max([child.branch_len() for child in self])
+
+    def sort(self, *args, **kwargs):
+        """
+        This method can be slow, e.g. on a branch_len() sort, since a
+        node at depth N from the root has it's branch_len() method
+        called N times.
+        """
+        list.sort(self, *args, **kwargs)
+        for child in self:
+            child.sort(*args, **kwargs)
+
+    def traverse(self, depth_first=True):
+        """
+        Note: you might want to sort() your tree first.
+        """
+        if depth_first == True:
+            yield self
+            for child in self:
+                for descendant in child.traverse():
+                    yield descendant
+        else: # breadth first, Wikipedia algorithm
+            # http://en.wikipedia.org/wiki/Breadth-first_search
+            queue = [self]
+            while len(queue) > 0:
+                node = queue.pop(0)
+                yield node
+                queue.extend(node)
+
+    def thread(self, flatten=False):
+        """
+        When flatten==False, the depth of any node is one greater than
+        the depth of its parent.  That way the inheritance is
+        explicit, but you can end up with highly indented threads.
+
+        When flatten==True, the depth of any node is only greater than
+        the depth of its parent when there is a branch, and the node
+        is not the last child.  This can lead to ancestry ambiguity,
+        but keeps the total indentation down.  E.g.
+                      +-b                  +-b-c
+                    a-+-c        and     a-+
+                      +-d-e-f              +-d-e-f
+        would both produce (after sorting by branch_len())
+        (0, a)
+        (1, b)
+        (1, c)
+        (0, d)
+        (0, e)
+        (0, f)
+        """
+        stack = [] # ancestry of the current node
+        if flatten == True:
+            depthDict = {}
+
+        for node in self.traverse(depth_first=True):
+            while len(stack) > 0 \
+                    and id(node) not in [id(c) for c in stack[-1]]:
+                stack.pop(-1)
+            if flatten == False:
+                depth = len(stack)
+            else:
+                if len(stack) == 0:
+                    depth = 0
+                else:
+                    parent = stack[-1]
+                    depth = depthDict[id(parent)]
+                    if len(parent) > 1 and node != parent[-1]:
+                        depth += 1
+                depthDict[id(node)] = depth
+            yield (depth,node)
+            stack.append(node)
+
+    def has_descendant(self, descendant, depth_first=True, match_self=False):
+        if descendant == self:
+            return match_self
+        for d in self.traverse(depth_first):
+            if descendant == d:
+                return True
+        return False
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/upgrade.py b/interfaces/email/interactive/libbe/upgrade.py
new file mode 100644 (file)
index 0000000..4123c72
--- /dev/null
@@ -0,0 +1,187 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Handle conversion between the various on-disk images.
+"""
+
+import os, os.path
+import sys
+import doctest
+
+import encoding
+import mapfile
+import vcs
+
+# a list of all past versions
+BUGDIR_DISK_VERSIONS = ["Bugs Everywhere Tree 1 0",
+                        "Bugs Everywhere Directory v1.1",
+                        "Bugs Everywhere Directory v1.2"]
+
+# the current version
+BUGDIR_DISK_VERSION = BUGDIR_DISK_VERSIONS[-1]
+
+class Upgrader (object):
+    "Class for converting "
+    initial_version = None
+    final_version = None
+    def __init__(self, root):
+        self.root = root
+        # use the "None" VCS to ensure proper encoding/decoding and
+        # simplify path construction.
+        self.vcs = vcs.vcs_by_name("None")
+        self.vcs.root(self.root)
+        self.vcs.encoding = encoding.get_encoding()
+
+    def get_path(self, *args):
+        """
+        Return a path relative to .root.
+        """
+        dir = os.path.join(self.root, ".be")
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["version", "settings", "bugs"], str(args)
+        return os.path.join(dir, *args)
+
+    def check_initial_version(self):
+        path = self.get_path("version")
+        version = self.vcs.get_file_contents(path).rstrip("\n")
+        assert version == self.initial_version, version
+
+    def set_version(self):
+        path = self.get_path("version")
+        self.vcs.set_file_contents(path, self.final_version+"\n")
+
+    def upgrade(self):
+        print >> sys.stderr, "upgrading bugdir from '%s' to '%s'" \
+            % (self.initial_version, self.final_version)
+        self.check_initial_version()
+        self.set_version()
+        self._upgrade()
+
+    def _upgrade(self):
+        raise NotImplementedError
+
+
+class Upgrade_1_0_to_1_1 (Upgrader):
+    initial_version = "Bugs Everywhere Tree 1 0"
+    final_version = "Bugs Everywhere Directory v1.1"
+    def _upgrade_mapfile(self, path):
+        contents = self.vcs.get_file_contents(path)
+        old_format = False
+        for line in contents.splitlines():
+            if len(line.split("=")) == 2:
+                old_format = True
+                break
+        if old_format == True:
+            # translate to YAML.
+            newlines = []
+            for line in contents.splitlines():
+                line = line.rstrip('\n')
+                if len(line) == 0:
+                    continue
+                fields = line.split("=")
+                if len(fields) == 2:
+                    key,value = fields
+                    newlines.append('%s: "%s"' % (key, value.replace('"','\\"')))
+                else:
+                    newlines.append(line)
+            contents = '\n'.join(newlines)
+            # load the YAML and save
+            map = mapfile.parse(contents)
+            mapfile.map_save(self.vcs, path, map)
+
+    def _upgrade(self):
+        """
+        Comment value field "From" -> "Author".
+        Homegrown mapfile -> YAML.
+        """
+        path = self.get_path("settings")
+        self._upgrade_mapfile(path)
+        for bug_uuid in os.listdir(self.get_path("bugs")):
+            path = self.get_path("bugs", bug_uuid, "values")
+            self._upgrade_mapfile(path)
+            c_path = ["bugs", bug_uuid, "comments"]
+            if not os.path.exists(self.get_path(*c_path)):
+                continue # no comments for this bug
+            for comment_uuid in os.listdir(self.get_path(*c_path)):
+                path_list = c_path + [comment_uuid, "values"]
+                path = self.get_path(*path_list)
+                self._upgrade_mapfile(path)
+                settings = mapfile.map_load(self.vcs, path)
+                if "From" in settings:
+                    settings["Author"] = settings.pop("From")
+                    mapfile.map_save(self.vcs, path, settings)
+
+
+class Upgrade_1_1_to_1_2 (Upgrader):
+    initial_version = "Bugs Everywhere Directory v1.1"
+    final_version = "Bugs Everywhere Directory v1.2"
+    def _upgrade(self):
+        """
+        BugDir settings field "rcs_name" -> "vcs_name".
+        """
+        path = self.get_path("settings")
+        settings = mapfile.map_load(self.vcs, path)
+        if "rcs_name" in settings:
+            settings["vcs_name"] = settings.pop("rcs_name")
+            mapfile.map_save(self.vcs, path, settings)
+
+
+upgraders = [Upgrade_1_0_to_1_1,
+             Upgrade_1_1_to_1_2]
+upgrade_classes = {}
+for upgrader in upgraders:
+    upgrade_classes[(upgrader.initial_version,upgrader.final_version)]=upgrader
+
+def upgrade(path, current_version,
+            target_version=BUGDIR_DISK_VERSION):
+    """
+    Call the appropriate upgrade function to convert current_version
+    to target_version.  If a direct conversion function does not exist,
+    use consecutive conversion functions.
+    """
+    if current_version not in BUGDIR_DISK_VERSIONS:
+        raise NotImplementedError, \
+            "Cannot handle version '%s' yet." % version
+    if target_version not in BUGDIR_DISK_VERSIONS:
+        raise NotImplementedError, \
+            "Cannot handle version '%s' yet." % version
+
+    if (current_version, target_version) in upgrade_classes:
+        # direct conversion
+        upgrade_class = upgrade_classes[(current_version, target_version)]
+        u = upgrade_class(path)
+        u.upgrade()
+    else:
+        # consecutive single-step conversion
+        i = BUGDIR_DISK_VERSIONS.index(current_version)
+        while True:
+            version_a = BUGDIR_DISK_VERSIONS[i]
+            version_b = BUGDIR_DISK_VERSIONS[i+1]
+            try:
+                upgrade_class = upgrade_classes[(version_a, version_b)]
+            except KeyError:
+                raise NotImplementedError, \
+                    "Cannot convert version '%s' to '%s' yet." \
+                    % (version_a, version_b)
+            u = upgrade_class(path)
+            u.upgrade()
+            if version_b == target_version:
+                break
+            i += 1
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/utility.py b/interfaces/email/interactive/libbe/utility.py
new file mode 100644 (file)
index 0000000..aafbf8d
--- /dev/null
@@ -0,0 +1,134 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Assorted utility functions that don't fit in anywhere else.
+"""
+
+import calendar
+import codecs
+import os
+import shutil
+import tempfile
+import time
+import types
+import doctest
+
+def search_parent_directories(path, filename):
+    """
+    Find the file (or directory) named filename in path or in any
+    of path's parents.
+    
+    e.g.
+    search_parent_directories("/a/b/c", ".be")
+    will return the path to the first existing file from
+    /a/b/c/.be
+    /a/b/.be
+    /a/.be
+    /.be
+    or None if none of those files exist.
+    """
+    path = os.path.realpath(path)
+    assert os.path.exists(path)
+    old_path = None
+    while True:
+        check_path = os.path.join(path, filename)
+        if os.path.exists(check_path):
+            return check_path
+        if path == old_path:
+            return None
+        old_path = path
+        path = os.path.dirname(path)
+
+class Dir (object):
+    "A temporary directory for testing use"
+    def __init__(self):
+        self.path = tempfile.mkdtemp(prefix="BEtest")
+        self.rmtree = shutil.rmtree # save local reference for __del__
+        self.removed = False
+    def __del__(self):
+        self.cleanup()
+    def cleanup(self):
+        if self.removed == False:
+            self.rmtree(self.path)
+            self.removed = True
+    def __call__(self):
+        return self.path
+
+RFC_2822_TIME_FMT = "%a, %d %b %Y %H:%M:%S +0000"
+
+
+def time_to_str(time_val):
+    """Convert a time value into an RFC 2822-formatted string.  This format
+    lacks sub-second data.
+    >>> time_to_str(0)
+    'Thu, 01 Jan 1970 00:00:00 +0000'
+    """
+    return time.strftime(RFC_2822_TIME_FMT, time.gmtime(time_val))
+
+def str_to_time(str_time):
+    """Convert an RFC 2822-fomatted string into a time value.
+    >>> str_to_time("Thu, 01 Jan 1970 00:00:00 +0000")
+    0
+    >>> q = time.time()
+    >>> str_to_time(time_to_str(q)) == int(q)
+    True
+    >>> str_to_time("Thu, 01 Jan 1970 00:00:00 -1000")
+    36000
+    """
+    timezone_str = str_time[-5:]
+    if timezone_str != "+0000":
+        str_time = str_time.replace(timezone_str, "+0000")
+    time_val = calendar.timegm(time.strptime(str_time, RFC_2822_TIME_FMT))
+    timesign = -int(timezone_str[0]+"1") # "+" -> time_val ahead of GMT
+    timezone_tuple = time.strptime(timezone_str[1:], "%H%M")
+    timezone = timezone_tuple.tm_hour*3600 + timezone_tuple.tm_min*60 
+    return time_val + timesign*timezone
+
+def handy_time(time_val):
+    return time.strftime("%a, %d %b %Y %H:%M", time.localtime(time_val))
+
+def time_to_gmtime(str_time):
+    """Convert an RFC 2822-fomatted string to a GMT string.
+    >>> time_to_gmtime("Thu, 01 Jan 1970 00:00:00 -1000")
+    'Thu, 01 Jan 1970 10:00:00 +0000'
+    """
+    time_val = str_to_time(str_time)
+    return time_to_str(time_val)
+
+def iterable_full_of_strings(value, alternative=None):
+    """
+    Require an iterable full of strings.
+    >>> iterable_full_of_strings([])
+    True
+    >>> iterable_full_of_strings(["abc", "def", u"hij"])
+    True
+    >>> iterable_full_of_strings(["abc", None, u"hij"])
+    False
+    >>> iterable_full_of_strings(None, alternative=None)
+    True
+    """
+    if value == alternative:
+        return True
+    elif not hasattr(value, "__iter__"):
+        return False
+    for x in value:
+        if type(x) not in types.StringTypes:
+            return False
+    return True
+
+suite = doctest.DocTestSuite()
diff --git a/interfaces/email/interactive/libbe/vcs.py b/interfaces/email/interactive/libbe/vcs.py
new file mode 100644 (file)
index 0000000..a1d3022
--- /dev/null
@@ -0,0 +1,938 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Alexander Belchenko <bialix@ukr.net>
+#                         Ben Finney <ben+python@benfinney.id.au>
+#                         Chris Ball <cjb@laptop.org>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define the base VCS (Version Control System) class, which should be
+subclassed by other Version Control System backends.  The base class
+implements a "do not version" VCS.
+"""
+
+from subprocess import Popen, PIPE
+import codecs
+import os
+import os.path
+import re
+from socket import gethostname
+import shutil
+import sys
+import tempfile
+import unittest
+import doctest
+
+from utility import Dir, search_parent_directories
+
+
+def _get_matching_vcs(matchfn):
+    """Return the first module for which matchfn(VCS_instance) is true"""
+    import arch
+    import bzr
+    import darcs
+    import git
+    import hg
+    for module in [arch, bzr, darcs, git, hg]:
+        vcs = module.new()
+        if matchfn(vcs) == True:
+            return vcs
+        del(vcs)
+    return VCS()
+    
+def vcs_by_name(vcs_name):
+    """Return the module for the VCS with the given name"""
+    return _get_matching_vcs(lambda vcs: vcs.name == vcs_name)
+
+def detect_vcs(dir):
+    """Return an VCS instance for the vcs being used in this directory"""
+    return _get_matching_vcs(lambda vcs: vcs.detect(dir))
+
+def installed_vcs():
+    """Return an instance of an installed VCS"""
+    return _get_matching_vcs(lambda vcs: vcs.installed())
+
+
+class CommandError(Exception):
+    def __init__(self, command, status, stdout, stderr):
+        strerror = ["Command failed (%d):\n  %s\n" % (status, stderr),
+                    "while executing\n  %s" % command]
+        Exception.__init__(self, "\n".join(strerror))
+        self.command = command
+        self.status = status
+        self.stdout = stdout
+        self.stderr = stderr
+
+class SettingIDnotSupported(NotImplementedError):
+    pass
+
+class VCSnotRooted(Exception):
+    def __init__(self):
+        msg = "VCS not rooted"
+        Exception.__init__(self, msg)
+
+class PathNotInRoot(Exception):
+    def __init__(self, path, root):
+        msg = "Path '%s' not in root '%s'" % (path, root)
+        Exception.__init__(self, msg)
+        self.path = path
+        self.root = root
+
+class NoSuchFile(Exception):
+    def __init__(self, pathname, root="."):
+        path = os.path.abspath(os.path.join(root, pathname))
+        Exception.__init__(self, "No such file: %s" % path)
+
+class EmptyCommit(Exception):
+    def __init__(self):
+        Exception.__init__(self, "No changes to commit")
+
+
+def new():
+    return VCS()
+
+class VCS(object):
+    """
+    This class implements a 'no-vcs' interface.
+
+    Support for other VCSs can be added by subclassing this class, and
+    overriding methods _vcs_*() with code appropriate for your VCS.
+    
+    The methods _u_*() are utility methods available to the _vcs_*()
+    methods.
+    """
+    name = "None"
+    client = "" # command-line tool for _u_invoke_client
+    versioned = False
+    def __init__(self, paranoid=False, encoding=sys.getdefaultencoding()):
+        self.paranoid = paranoid
+        self.verboseInvoke = False
+        self.rootdir = None
+        self._duplicateBasedir = None
+        self._duplicateDirname = None
+        self.encoding = encoding
+    def __del__(self):
+        self.cleanup()
+
+    def _vcs_help(self):
+        """
+        Return the command help string.
+        (Allows a simple test to see if the client is installed.)
+        """
+        pass
+    def _vcs_detect(self, path=None):
+        """
+        Detect whether a directory is revision controlled with this VCS.
+        """
+        return True
+    def _vcs_root(self, path):
+        """
+        Get the VCS root.  This is the default working directory for
+        future invocations.  You would normally set this to the root
+        directory for your VCS.
+        """
+        if os.path.isdir(path)==False:
+            path = os.path.dirname(path)
+            if path == "":
+                path = os.path.abspath(".")
+        return path
+    def _vcs_init(self, path):
+        """
+        Begin versioning the tree based at path.
+        """
+        pass
+    def _vcs_cleanup(self):
+        """
+        Remove any cruft that _vcs_init() created outside of the
+        versioned tree.
+        """
+        pass
+    def _vcs_get_user_id(self):
+        """
+        Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
+        If the VCS has not been configured with a username, return None.
+        """
+        return None
+    def _vcs_set_user_id(self, value):
+        """
+        Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>").
+        This is run if the VCS has not been configured with a usename, so
+        that commits will have a reasonable FROM value.
+        """
+        raise SettingIDnotSupported
+    def _vcs_add(self, path):
+        """
+        Add the already created file at path to version control.
+        """
+        pass
+    def _vcs_remove(self, path):
+        """
+        Remove the file at path from version control.  Optionally
+        remove the file from the filesystem as well.
+        """
+        pass
+    def _vcs_update(self, path):
+        """
+        Notify the versioning system of changes to the versioned file
+        at path.
+        """
+        pass
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
+        """
+        Get the file contents as they were in a given revision.
+        Revision==None specifies the current revision.
+        """
+        assert revision == None, \
+            "The %s VCS does not support revision specifiers" % self.name
+        if binary == False:
+            f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding)
+        else:
+            f = open(os.path.join(self.rootdir, path), "rb")
+        contents = f.read()
+        f.close()
+        return contents
+    def _vcs_duplicate_repo(self, directory, revision=None):
+        """
+        Get the repository as it was in a given revision.
+        revision==None specifies the current revision.
+        dir specifies a directory to create the duplicate in.
+        """
+        shutil.copytree(self.rootdir, directory, True)
+    def _vcs_commit(self, commitfile, allow_empty=False):
+        """
+        Commit the current working directory, using the contents of
+        commitfile as the comment.  Return the name of the old
+        revision (or None if commits are not supported).
+        
+        If allow_empty == False, raise EmptyCommit if there are no
+        changes to commit.
+        """
+        return None
+    def _vcs_revision_id(self, index):
+        """
+        Return the name of the <index>th revision.  Index will be an
+        integer (possibly <= 0).  The choice of which branch to follow
+        when crossing branches/merges is not defined.
+
+        Return None if revision IDs are not supported, or if the
+        specified revision does not exist.
+        """
+        return None
+    def installed(self):
+        try:
+            self._vcs_help()
+            return True
+        except OSError, e:
+            if e.errno == errno.ENOENT:
+                return False
+        except CommandError:
+            return False
+    def detect(self, path="."):
+        """
+        Detect whether a directory is revision controlled with this VCS.
+        """
+        return self._vcs_detect(path)
+    def root(self, path):
+        """
+        Set the root directory to the path's VCS root.  This is the
+        default working directory for future invocations.
+        """
+        self.rootdir = self._vcs_root(path)
+    def init(self, path):
+        """
+        Begin versioning the tree based at path.
+        Also roots the vcs at path.
+        """
+        if os.path.isdir(path)==False:
+            path = os.path.dirname(path)
+        self._vcs_init(path)
+        self.root(path)
+    def cleanup(self):
+        self._vcs_cleanup()
+    def get_user_id(self):
+        """
+        Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
+        If the VCS has not been configured with a username, return the user's
+        id.  You can override the automatic lookup procedure by setting the
+        VCS.user_id attribute to a string of your choice.
+        """
+        if hasattr(self, "user_id"):
+            if self.user_id != None:
+                return self.user_id
+        id = self._vcs_get_user_id()
+        if id == None:
+            name = self._u_get_fallback_username()
+            email = self._u_get_fallback_email()
+            id = self._u_create_id(name, email)
+            print >> sys.stderr, "Guessing id '%s'" % id
+            try:
+                self.set_user_id(id)
+            except SettingIDnotSupported:
+                pass
+        return id
+    def set_user_id(self, value):
+        """
+        Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>").
+        This is run if the VCS has not been configured with a usename, so
+        that commits will have a reasonable FROM value.
+        """
+        self._vcs_set_user_id(value)
+    def add(self, path):
+        """
+        Add the already created file at path to version control.
+        """
+        self._vcs_add(self._u_rel_path(path))
+    def remove(self, path):
+        """
+        Remove a file from both version control and the filesystem.
+        """
+        self._vcs_remove(self._u_rel_path(path))
+        if os.path.exists(path):
+            os.remove(path)
+    def recursive_remove(self, dirname):
+        """
+        Remove a file/directory and all its decendents from both
+        version control and the filesystem.
+        """
+        if not os.path.exists(dirname):
+            raise NoSuchFile(dirname)
+        for dirpath,dirnames,filenames in os.walk(dirname, topdown=False):
+            filenames.extend(dirnames)
+            for path in filenames:
+                fullpath = os.path.join(dirpath, path)
+                if os.path.exists(fullpath) == False:
+                    continue
+                self._vcs_remove(self._u_rel_path(fullpath))
+        if os.path.exists(dirname):
+            shutil.rmtree(dirname)
+    def update(self, path):
+        """
+        Notify the versioning system of changes to the versioned file
+        at path.
+        """
+        self._vcs_update(self._u_rel_path(path))
+    def get_file_contents(self, path, revision=None, allow_no_vcs=False, binary=False):
+        """
+        Get the file as it was in a given revision.
+        Revision==None specifies the current revision.
+        """
+        if not os.path.exists(path):
+            raise NoSuchFile(path)
+        if self._use_vcs(path, allow_no_vcs):
+            relpath = self._u_rel_path(path)
+            contents = self._vcs_get_file_contents(relpath,revision,binary=binary)
+        else:
+            f = codecs.open(path, "r", self.encoding)
+            contents = f.read()
+            f.close()
+        return contents
+    def set_file_contents(self, path, contents, allow_no_vcs=False, binary=False):
+        """
+        Set the file contents under version control.
+        """
+        add = not os.path.exists(path)
+        if binary == False:
+            f = codecs.open(path, "w", self.encoding)
+        else:
+            f = open(path, "wb")
+        f.write(contents)
+        f.close()
+        
+        if self._use_vcs(path, allow_no_vcs):
+            if add:
+                self.add(path)
+            else:
+                self.update(path)
+    def mkdir(self, path, allow_no_vcs=False, check_parents=True):
+        """
+        Create (if neccessary) a directory at path under version
+        control.
+        """
+        if check_parents == True:
+            parent = os.path.dirname(path)
+            if not os.path.exists(parent): # recurse through parents
+                self.mkdir(parent, allow_no_vcs, check_parents)
+        if not os.path.exists(path):
+            os.mkdir(path)
+            if self._use_vcs(path, allow_no_vcs):
+                self.add(path)
+        else:
+            assert os.path.isdir(path)
+            if self._use_vcs(path, allow_no_vcs):
+                #self.update(path)# Don't update directories.  Changing files
+                pass              # underneath them should be sufficient.
+                
+    def duplicate_repo(self, revision=None):
+        """
+        Get the repository as it was in a given revision.
+        revision==None specifies the current revision.
+        Return the path to the arbitrary directory at the base of the new repo.
+        """
+        # Dirname in Baseir to protect against simlink attacks.
+        if self._duplicateBasedir == None:
+            self._duplicateBasedir = tempfile.mkdtemp(prefix='BEvcs')
+            self._duplicateDirname = \
+                os.path.join(self._duplicateBasedir, "duplicate")
+            self._vcs_duplicate_repo(directory=self._duplicateDirname,
+                                     revision=revision)
+        return self._duplicateDirname
+    def remove_duplicate_repo(self):
+        """
+        Clean up a duplicate repo created with duplicate_repo().
+        """
+        if self._duplicateBasedir != None:
+            shutil.rmtree(self._duplicateBasedir)
+            self._duplicateBasedir = None
+            self._duplicateDirname = None
+    def commit(self, summary, body=None, allow_empty=False):
+        """
+        Commit the current working directory, with a commit message
+        string summary and body.  Return the name of the old revision
+        (or None if versioning is not supported).
+        
+        If allow_empty == False (the default), raise EmptyCommit if
+        there are no changes to commit.
+        """
+        summary = summary.strip()+'\n'
+        if body is not None:
+            summary += '\n' + body.strip() + '\n'
+        descriptor, filename = tempfile.mkstemp()
+        revision = None
+        try:
+            temp_file = os.fdopen(descriptor, 'wb')
+            temp_file.write(summary)
+            temp_file.flush()
+            self.precommit()
+            revision = self._vcs_commit(filename, allow_empty=allow_empty)
+            temp_file.close()
+            self.postcommit()
+        finally:
+            os.remove(filename)
+        return revision
+    def precommit(self):
+        """
+        Executed before all attempted commits.
+        """
+        pass
+    def postcommit(self):
+        """
+        Only executed after successful commits.
+        """
+        pass
+    def revision_id(self, index=None):
+        """
+        Return the name of the <index>th revision.  The choice of
+        which branch to follow when crossing branches/merges is not
+        defined.
+
+        Return None if index==None, revision IDs are not supported, or
+        if the specified revision does not exist.
+        """
+        if index == None:
+            return None
+        return self._vcs_revision_id(index)
+    def _u_any_in_string(self, list, string):
+        """
+        Return True if any of the strings in list are in string.
+        Otherwise return False.
+        """
+        for list_string in list:
+            if list_string in string:
+                return True
+        return False
+    def _u_invoke(self, args, stdin=None, expect=(0,), cwd=None):
+        """
+        expect should be a tuple of allowed exit codes.  cwd should be
+        the directory from which the command will be executed.
+        """
+        if cwd == None:
+            cwd = self.rootdir
+        if self.verboseInvoke == True:
+            print >> sys.stderr, "%s$ %s" % (cwd, " ".join(args))
+        try :
+            if sys.platform != "win32":
+                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd)
+            else:
+                # win32 don't have os.execvp() so have to run command in a shell
+                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, 
+                          shell=True, cwd=cwd)
+        except OSError, e :
+            raise CommandError(args, status=e.args[0], stdout="", stderr=e)
+        output,error = q.communicate(input=stdin)
+        status = q.wait()
+        if self.verboseInvoke == True:
+            print >> sys.stderr, "%d\n%s%s" % (status, output, error)
+        if status not in expect:
+            raise CommandError(args, status, output, error)
+        return status, output, error
+    def _u_invoke_client(self, *args, **kwargs):
+        directory = kwargs.get('directory',None)
+        expect = kwargs.get('expect', (0,))
+        stdin = kwargs.get('stdin', None)
+        cl_args = [self.client]
+        cl_args.extend(args)
+        return self._u_invoke(cl_args, stdin=stdin,expect=expect,cwd=directory)
+    def _u_search_parent_directories(self, path, filename):
+        """
+        Find the file (or directory) named filename in path or in any
+        of path's parents.
+        
+        e.g.
+          search_parent_directories("/a/b/c", ".be")
+        will return the path to the first existing file from
+          /a/b/c/.be
+          /a/b/.be
+          /a/.be
+          /.be
+        or None if none of those files exist.
+        """
+        return search_parent_directories(path, filename)
+    def _use_vcs(self, path, allow_no_vcs):
+        """
+        Try and decide if _vcs_add/update/mkdir/etc calls will
+        succeed.  Returns True is we think the vcs_call would
+        succeeed, and False otherwise.
+        """
+        use_vcs = True
+        exception = None
+        if self.rootdir != None:
+            if self.path_in_root(path) == False:
+                use_vcs = False
+                exception = PathNotInRoot(path, self.rootdir)
+        else:
+            use_vcs = False
+            exception = VCSnotRooted
+        if use_vcs == False and allow_no_vcs==False:
+            raise exception
+        return use_vcs
+    def path_in_root(self, path, root=None):
+        """
+        Return the relative path to path from root.
+        >>> vcs = new()
+        >>> vcs.path_in_root("/a.b/c/.be", "/a.b/c")
+        True
+        >>> vcs.path_in_root("/a.b/.be", "/a.b/c")
+        False
+        """
+        if root == None:
+            if self.rootdir == None:
+                raise VCSnotRooted
+            root = self.rootdir
+        path = os.path.abspath(path)
+        absRoot = os.path.abspath(root)
+        absRootSlashedDir = os.path.join(absRoot,"")
+        if not path.startswith(absRootSlashedDir):
+            return False
+        return True
+    def _u_rel_path(self, path, root=None):
+        """
+        Return the relative path to path from root.
+        >>> vcs = new()
+        >>> vcs._u_rel_path("/a.b/c/.be", "/a.b/c")
+        '.be'
+        """
+        if root == None:
+            if self.rootdir == None:
+                raise VCSnotRooted
+            root = self.rootdir
+        path = os.path.abspath(path)
+        absRoot = os.path.abspath(root)
+        absRootSlashedDir = os.path.join(absRoot,"")
+        if not path.startswith(absRootSlashedDir):
+            raise PathNotInRoot(path, absRootSlashedDir)
+        assert path != absRootSlashedDir, \
+            "file %s == root directory %s" % (path, absRootSlashedDir)
+        relpath = path[len(absRootSlashedDir):]
+        return relpath
+    def _u_abspath(self, path, root=None):
+        """
+        Return the absolute path from a path realtive to root.
+        >>> vcs = new()
+        >>> vcs._u_abspath(".be", "/a.b/c")
+        '/a.b/c/.be'
+        """
+        if root == None:
+            assert self.rootdir != None, "VCS not rooted"
+            root = self.rootdir
+        return os.path.abspath(os.path.join(root, path))
+    def _u_create_id(self, name, email=None):
+        """
+        >>> vcs = new()
+        >>> vcs._u_create_id("John Doe", "jdoe@example.com")
+        'John Doe <jdoe@example.com>'
+        >>> vcs._u_create_id("John Doe")
+        'John Doe'
+        """
+        assert len(name) > 0
+        if email == None or len(email) == 0:
+            return name
+        else:
+            return "%s <%s>" % (name, email)
+    def _u_parse_id(self, value):
+        """
+        >>> vcs = new()
+        >>> vcs._u_parse_id("John Doe <jdoe@example.com>")
+        ('John Doe', 'jdoe@example.com')
+        >>> vcs._u_parse_id("John Doe")
+        ('John Doe', None)
+        >>> try:
+        ...     vcs._u_parse_id("John Doe <jdoe@example.com><what?>")
+        ... except AssertionError:
+        ...     print "Invalid match"
+        Invalid match
+        """
+        emailexp = re.compile("(.*) <([^>]*)>(.*)")
+        match = emailexp.search(value)
+        if match == None:
+            email = None
+            name = value
+        else:
+            assert len(match.groups()) == 3
+            assert match.groups()[2] == "", match.groups()
+            email = match.groups()[1]
+            name = match.groups()[0]
+        assert name != None
+        assert len(name) > 0
+        return (name, email)
+    def _u_get_fallback_username(self):
+        name = None
+        for envariable in ["LOGNAME", "USERNAME"]:
+            if os.environ.has_key(envariable):
+                name = os.environ[envariable]
+                break
+        assert name != None
+        return name
+    def _u_get_fallback_email(self):
+        hostname = gethostname()
+        name = self._u_get_fallback_username()
+        return "%s@%s" % (name, hostname)
+    def _u_parse_commitfile(self, commitfile):
+        """
+        Split the commitfile created in self.commit() back into
+        summary and header lines.
+        """
+        f = codecs.open(commitfile, "r", self.encoding)
+        summary = f.readline()
+        body = f.read()
+        body.lstrip('\n')
+        if len(body) == 0:
+            body = None
+        f.close()
+        return (summary, body)
+        
+\f
+def setup_vcs_test_fixtures(testcase):
+    """Set up test fixtures for VCS test case."""
+    testcase.vcs = testcase.Class()
+    testcase.dir = Dir()
+    testcase.dirname = testcase.dir.path
+
+    vcs_not_supporting_uninitialized_user_id = []
+    vcs_not_supporting_set_user_id = ["None", "hg"]
+    testcase.vcs_supports_uninitialized_user_id = (
+        testcase.vcs.name not in vcs_not_supporting_uninitialized_user_id)
+    testcase.vcs_supports_set_user_id = (
+        testcase.vcs.name not in vcs_not_supporting_set_user_id)
+
+    if not testcase.vcs.installed():
+        testcase.fail(
+            "%(name)s VCS not found" % vars(testcase.Class))
+
+    if testcase.Class.name != "None":
+        testcase.failIf(
+            testcase.vcs.detect(testcase.dirname),
+            "Detected %(name)s VCS before initialising"
+                % vars(testcase.Class))
+
+    testcase.vcs.init(testcase.dirname)
+
+
+class VCSTestCase(unittest.TestCase):
+    """Test cases for base VCS class."""
+
+    Class = VCS
+
+    def __init__(self, *args, **kwargs):
+        super(VCSTestCase, self).__init__(*args, **kwargs)
+        self.dirname = None
+
+    def setUp(self):
+        super(VCSTestCase, self).setUp()
+        setup_vcs_test_fixtures(self)
+
+    def tearDown(self):
+        del(self.vcs)
+        super(VCSTestCase, self).tearDown()
+
+    def full_path(self, rel_path):
+        return os.path.join(self.dirname, rel_path)
+
+
+class VCS_init_TestCase(VCSTestCase):
+    """Test cases for VCS.init method."""
+
+    def test_detect_should_succeed_after_init(self):
+        """Should detect VCS in directory after initialization."""
+        self.failUnless(
+            self.vcs.detect(self.dirname),
+            "Did not detect %(name)s VCS after initialising"
+                % vars(self.Class))
+
+    def test_vcs_rootdir_in_specified_root_path(self):
+        """VCS root directory should be in specified root path."""
+        rp = os.path.realpath(self.vcs.rootdir)
+        dp = os.path.realpath(self.dirname)
+        vcs_name = self.Class.name
+        self.failUnless(
+            dp == rp or rp == None,
+            "%(vcs_name)s VCS root in wrong dir (%(dp)s %(rp)s)" % vars())
+
+
+class VCS_get_user_id_TestCase(VCSTestCase):
+    """Test cases for VCS.get_user_id method."""
+
+    def test_gets_existing_user_id(self):
+        """Should get the existing user ID."""
+        if not self.vcs_supports_uninitialized_user_id:
+            return
+
+        user_id = self.vcs.get_user_id()
+        self.failUnless(
+            user_id is not None,
+            "unable to get a user id")
+
+
+class VCS_set_user_id_TestCase(VCSTestCase):
+    """Test cases for VCS.set_user_id method."""
+
+    def setUp(self):
+        super(VCS_set_user_id_TestCase, self).setUp()
+
+        if self.vcs_supports_uninitialized_user_id:
+            self.prev_user_id = self.vcs.get_user_id()
+        else:
+            self.prev_user_id = "Uninitialized identity <bogus@example.org>"
+
+        if self.vcs_supports_set_user_id:
+            self.test_new_user_id = "John Doe <jdoe@example.com>"
+            self.vcs.set_user_id(self.test_new_user_id)
+
+    def tearDown(self):
+        if self.vcs_supports_set_user_id:
+            self.vcs.set_user_id(self.prev_user_id)
+        super(VCS_set_user_id_TestCase, self).tearDown()
+
+    def test_raises_error_in_unsupported_vcs(self):
+        """Should raise an error in a VCS that doesn't support it."""
+        if self.vcs_supports_set_user_id:
+            return
+        self.assertRaises(
+            SettingIDnotSupported,
+            self.vcs.set_user_id, "foo")
+
+    def test_updates_user_id_in_supporting_vcs(self):
+        """Should update the user ID in an VCS that supports it."""
+        if not self.vcs_supports_set_user_id:
+            return
+        user_id = self.vcs.get_user_id()
+        self.failUnlessEqual(
+            self.test_new_user_id, user_id,
+            "user id not set correctly (expected %s, got %s)"
+                % (self.test_new_user_id, user_id))
+
+
+def setup_vcs_revision_test_fixtures(testcase):
+    """Set up revision test fixtures for VCS test case."""
+    testcase.test_dirs = ['a', 'a/b', 'c']
+    for path in testcase.test_dirs:
+        testcase.vcs.mkdir(testcase.full_path(path))
+
+    testcase.test_files = ['a/text', 'a/b/text']
+
+    testcase.test_contents = {
+        'rev_1': "Lorem ipsum",
+        'uncommitted': "dolor sit amet",
+        }
+
+
+class VCS_mkdir_TestCase(VCSTestCase):
+    """Test cases for VCS.mkdir method."""
+
+    def setUp(self):
+        super(VCS_mkdir_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_mkdir_TestCase, self).tearDown()
+
+    def test_mkdir_creates_directory(self):
+        """Should create specified directory in filesystem."""
+        for path in self.test_dirs:
+            full_path = self.full_path(path)
+            self.failUnless(
+                os.path.exists(full_path),
+                "path %(full_path)s does not exist" % vars())
+
+
+class VCS_commit_TestCase(VCSTestCase):
+    """Test cases for VCS.commit method."""
+
+    def setUp(self):
+        super(VCS_commit_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_commit_TestCase, self).tearDown()
+
+    def test_file_contents_as_specified(self):
+        """Should set file contents as specified."""
+        test_contents = self.test_contents['rev_1']
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(full_path, test_contents)
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(test_contents, current_contents)
+
+    def test_file_contents_as_committed(self):
+        """Should have file contents as specified after commit."""
+        test_contents = self.test_contents['rev_1']
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(full_path, test_contents)
+            revision = self.vcs.commit("Initial file contents.")
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(test_contents, current_contents)
+
+    def test_file_contents_as_set_when_uncommitted(self):
+        """Should set file contents as specified after commit."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial file contents.")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(
+                self.test_contents['uncommitted'], current_contents)
+
+    def test_revision_file_contents_as_committed(self):
+        """Should get file contents as committed to specified revision."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial file contents.")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            committed_contents = self.vcs.get_file_contents(
+                full_path, revision)
+            self.failUnlessEqual(
+                self.test_contents['rev_1'], committed_contents)
+
+    def test_revision_id_as_committed(self):
+        """Check for compatibility between .commit() and .revision_id()"""
+        if not self.vcs.versioned:
+            self.failUnlessEqual(self.vcs.revision_id(5), None)
+            return
+        committed_revisions = []
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial %s contents." % path)
+            committed_revisions.append(revision)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            revision = self.vcs.commit("Altered %s contents." % path)
+            committed_revisions.append(revision)
+        for i,revision in enumerate(committed_revisions):
+            self.failUnlessEqual(self.vcs.revision_id(i), revision)
+            i += -len(committed_revisions) # check negative indices
+            self.failUnlessEqual(self.vcs.revision_id(i), revision)
+        i = len(committed_revisions)
+        self.failUnlessEqual(self.vcs.revision_id(i), None)
+        self.failUnlessEqual(self.vcs.revision_id(-i-1), None)
+
+    def test_revision_id_as_committed(self):
+        """Check revision id before first commit"""
+        if not self.vcs.versioned:
+            self.failUnlessEqual(self.vcs.revision_id(5), None)
+            return
+        committed_revisions = []
+        for path in self.test_files:
+            self.failUnlessEqual(self.vcs.revision_id(0), None)
+
+
+class VCS_duplicate_repo_TestCase(VCSTestCase):
+    """Test cases for VCS.duplicate_repo method."""
+
+    def setUp(self):
+        super(VCS_duplicate_repo_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        self.vcs.remove_duplicate_repo()
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_duplicate_repo_TestCase, self).tearDown()
+
+    def test_revision_file_contents_as_committed(self):
+        """Should match file contents as committed to specified revision."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Commit current status")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            dup_repo_path = self.vcs.duplicate_repo(revision)
+            dup_file_path = os.path.join(dup_repo_path, path)
+            dup_file_contents = file(dup_file_path, 'rb').read()
+            self.failUnlessEqual(
+                self.test_contents['rev_1'], dup_file_contents)
+            self.vcs.remove_duplicate_repo()
+
+
+def make_vcs_testcase_subclasses(vcs_class, namespace):
+    """Make VCSTestCase subclasses for vcs_class in the namespace."""
+    vcs_testcase_classes = [
+        c for c in (
+            ob for ob in globals().values() if isinstance(ob, type))
+        if issubclass(c, VCSTestCase)]
+
+    for base_class in vcs_testcase_classes:
+        testcase_class_name = vcs_class.__name__ + base_class.__name__
+        testcase_class_bases = (base_class,)
+        testcase_class_dict = dict(base_class.__dict__)
+        testcase_class_dict['Class'] = vcs_class
+        testcase_class = type(
+            testcase_class_name, testcase_class_bases, testcase_class_dict)
+        setattr(namespace, testcase_class_name, testcase_class)
+
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/email/interactive/libbe/version.py b/interfaces/email/interactive/libbe/version.py
new file mode 100644 (file)
index 0000000..f8eebbd
--- /dev/null
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Store version info for this BE installation.  By default, use the
+bzr-generated information in _version.py, but allow manual overriding
+by setting _VERSION.  This allows support of both the "I don't want to
+be bothered setting version strings" and the "I want complete control
+over the version strings" workflows.
+"""
+
+import libbe._version as _version
+
+# Manually set a version string (optional, defaults to bzr revision id)
+#_VERSION = "1.2.3"
+
+def version(verbose=False):
+    """
+    Returns the version string for this BE installation.  If
+    verbose==True, the string will include extra lines with more
+    detail (e.g. bzr branch nickname, etc.).
+    """
+    if "_VERSION" in globals():
+        string = _VERSION
+    else:
+        string = _version.version_info["revision_id"]
+    if verbose == True:
+        string += ("\n"
+                   "revision: %(revno)d\n"
+                   "nick: %(branch_nick)s\n"
+                   "revision id: %(revision_id)s"
+                   % _version.version_info)
+    return string
+
+if __name__ == "__main__":
+    print version(verbose=True)
diff --git a/interfaces/email/interactive/send_pgp_mime.py b/interfaces/email/interactive/send_pgp_mime.py
new file mode 100644 (file)
index 0000000..c19483e
--- /dev/null
@@ -0,0 +1,611 @@
+#!/usr/bin/python
+#
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+"""
+Python module and command line tool for sending pgp/mime email.
+
+Mostly uses subprocess to call gpg and a sendmail-compatible mailer.
+If you lack gpg, either don't use the encryption functions or adjust
+the pgp_* commands.  You may need to adjust the sendmail command to
+point to whichever sendmail-compatible mailer you have on your system.
+"""
+
+from cStringIO import StringIO
+import os
+import re
+#import GnuPGInterface # Maybe should use this instead of subprocess
+import smtplib
+import subprocess
+import sys
+import tempfile
+import types
+
+try:
+    from email import Message
+    from email.mime.text import MIMEText
+    from email.mime.multipart import MIMEMultipart
+    from email.mime.application import MIMEApplication
+    from email.encoders import encode_7or8bit
+    from email.generator import Generator
+    from email.parser import Parser
+    from email.utils import getaddress
+except ImportError:
+    # adjust to old python 2.4
+    from email import Message
+    from email.MIMEText import MIMEText
+    from email.MIMEMultipart import MIMEMultipart
+    from email.MIMENonMultipart import MIMENonMultipart
+    from email.Encoders import encode_7or8bit
+    from email.Generator import Generator
+    from email.Parser import Parser
+    from email.Utils import getaddresses
+
+    getaddress = getaddresses
+    class MIMEApplication (MIMENonMultipart):
+        def __init__(self, _data, _subtype, _encoder, **params):
+            MIMENonMultipart.__init__(self, 'application', _subtype, **params)
+            self.set_payload(_data)
+            _encoder(self)
+
+usage="""usage: %prog [options]
+
+Scriptable PGP MIME email using gpg.
+
+You can use gpg-agent for passphrase caching if your key requires a
+passphrase (it better!).  Example usage would be to install gpg-agent,
+and then run
+  export GPG_TTY=`tty`
+  eval $(gpg-agent --daemon)
+in your shell before invoking this script.  See gpg-agent(1) for more
+details.  Alternatively, you can send your passphrase in on stdin
+  echo 'passphrase' | %prog [options]
+or use the --passphrase-file option
+  %prog [options] --passphrase-file FILE [more options]
+Both of these alternatives are much less secure than gpg-agent.  You
+have been warned.
+"""
+
+verboseInvoke = False
+PGP_SIGN_AS = None
+PASSPHRASE = None
+
+# The following commands are adapted from my .mutt/pgp configuration
+#
+# Printf-like sequences:
+#   %a The value of PGP_SIGN_AS.
+#   %f Expands to the name of a file with text to be signed/encrypted.
+#   %p Expands to the passphrase argument.
+#   %R A string with some number (0 on up) of pgp_reciepient_arg
+#      strings.
+#   %r One key ID (e.g. recipient email address) to build a
+#      pgp_reciepient_arg string.
+#
+# The above sequences can be used to optionally print a string if
+# their length is nonzero. For example, you may only want to pass the
+# -u/--local-user argument to gpg if PGP_SIGN_AS is defined.  To
+# optionally print a string based upon one of the above sequences, the
+# following construct is used
+#   %?<sequence_char>?<optional_string>?
+# where sequence_char is a character from the table above, and
+# optional_string is the string you would like printed if status_char
+# is nonzero. optional_string may contain other sequence as well as
+# normal text, but it may not contain any question marks.
+#
+# see http://codesorcery.net/old/mutt/mutt-gnupg-howto
+#     http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign
+#     http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html
+# for more details
+
+pgp_recipient_arg='-r "%r"'
+pgp_stdin_passphrase_arg='--passphrase-fd 0'
+pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f'
+pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
+pgp_encrypt_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --encrypt --sign %?a?-u "%a"? --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
+sendmail='/usr/sbin/sendmail -t'
+
+def mail(msg, sendmail=None):
+    """
+    Send an email Message instance on its merry way.
+
+    We can shell out to the user specified sendmail in case
+    the local host doesn't have an SMTP server set up
+    for easy smtplib usage.
+    """
+    if sendmail != None:
+        execute(sendmail, stdin=flatten(msg))
+        return None
+    s = smtplib.SMTP()
+    s.connect()
+    s.sendmail(from_addr=source_email(msg),
+               to_addrs=target_emails(msg),
+               msg=flatten(msg))
+    s.close()
+
+def header_from_text(text, encoding="us-ascii"):
+    """
+    Simple wrapper for instantiating an email.Message from text.
+    >>> header = header_from_text('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']))
+    >>> print flatten(header)
+    From: me@big.edu
+    To: you@big.edu
+    Subject: testing
+    <BLANKLINE>
+    <BLANKLINE>
+    """
+    text = text.strip()
+    if type(text) == types.UnicodeType:
+        text = text.encode(encoding)
+    # assume StringType arguments are already encoded
+    p = Parser()
+    return p.parsestr(text, headersonly=True)
+
+def guess_encoding(text):
+    if type(text) == types.StringType:
+        encoding = "us-ascii"
+    elif type(text) == types.UnicodeType:
+        for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
+            try:
+                text.encode(encoding)
+            except UnicodeError:
+                pass
+            else:
+                break
+        assert encoding != None
+    return encoding
+
+def encodedMIMEText(body, encoding=None):
+    if encoding == None:
+        encoding = guess_encoding(body)
+    if encoding == "us-ascii":
+        return MIMEText(body)
+    else:
+        # Create the message ('plain' stands for Content-Type: text/plain)
+        return MIMEText(body.encode(encoding), 'plain', encoding)
+
+def append_text(text_part, new_text):
+    original_payload = text_part.get_payload(decode=True)
+    new_payload = u"%s%s" % (original_payload, new_text)
+    new_encoding = guess_encoding(new_payload)
+    text_part.set_payload(new_payload.encode(new_encoding), new_encoding)
+
+def attach_root(header, root_part):
+    """
+    Attach the email.Message root_part to the email.Message header
+    without generating a multi-part message.
+    """
+    for k,v in header.items():
+        root_part[k] = v
+    return root_part    
+
+def execute(args, stdin=None, expect=(0,)):
+    """
+    Execute a command (allows us to drive gpg).
+    """
+    if verboseInvoke == True:
+        print >> sys.stderr, '$ '+args
+    try:
+        p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
+    except OSError, e:
+        strerror = '%s\nwhile executing %s' % (e.args[1], args)
+        raise Exception, strerror
+    output, error = p.communicate(input=stdin)
+    status = p.wait()
+    if verboseInvoke == True:
+        print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
+    if status not in expect:
+        strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
+        raise Exception, strerror
+    return status, output, error
+
+def replace(template, format_char, replacement_text):
+    """
+    >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
+    '--textmode %?a?-u %a? file.in'
+    >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
+    '--textmode -u 0xHEXKEY %f'
+    >>> replace('--textmode %?a?-u %a? %f', 'a', '')
+    '--textmode  %f'
+    """
+    if replacement_text == None:
+        replacement_text = ""
+    regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
+    if len(replacement_text) > 0:
+        str = regexp.sub('\g<1>', template)
+    else:
+        str = regexp.sub('', template)
+    regexp = re.compile('%'+format_char)
+    str = regexp.sub(replacement_text, str)
+    return str
+
+def flatten(msg, to_unicode=False):
+    """
+    Produce flat text output from an email Message instance.
+    """
+    assert msg != None
+    fp = StringIO()
+    g = Generator(fp, mangle_from_=False)
+    g.flatten(msg)
+    text = fp.getvalue()
+    if to_unicode == True:
+        encoding = msg.get_content_charset() or "utf-8"
+        text = unicode(text, encoding=encoding)
+    return text
+
+def source_email(msg, return_realname=False):
+    """
+    Search the header of an email Message instance to find the
+    sender's email address.
+    """
+    froms = msg.get_all('from', [])
+    from_tuples = getaddresses(froms) # [(realname, email_address), ...]
+    assert len(from_tuples) == 1
+    if return_realname == True:
+        return from_tuples[0] # (realname, email_address)
+    return from_tuples[0][1]  # email_address
+
+def target_emails(msg):
+    """
+    Search the header of an email Message instance to find a
+    list of recipient's email addresses.
+    """
+    tos = msg.get_all('to', [])
+    ccs = msg.get_all('cc', [])
+    bccs = msg.get_all('bcc', [])
+    resent_tos = msg.get_all('resent-to', [])
+    resent_ccs = msg.get_all('resent-cc', [])
+    resent_bccs = msg.get_all('resent-bcc', [])
+    all_recipients = getaddresses(tos + ccs + bccs + resent_tos
+                                  + resent_ccs + resent_bccs)
+    return [addr[1] for addr in all_recipients]
+
+class PGPMimeMessageFactory (object):
+    """
+    See http://www.ietf.org/rfc/rfc3156.txt for specification details.
+    >>> from_addr = "me@big.edu"
+    >>> to_addr = "you@you.edu"
+    >>> header = header_from_text('\\n'.join(['From: %s'%from_addr,'To: %s'%to_addr,'Subject: testing']))
+    >>> source_email(header) == from_addr
+    True
+    >>> target_emails(header) == [to_addr]
+    True
+    >>> m = PGPMimeMessageFactory('check 1 2\\ncheck 1 2\\n')
+    >>> print flatten(m.clearBodyPart())
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Disposition: inline
+    <BLANKLINE>
+    check 1 2
+    check 1 2
+    <BLANKLINE>
+    >>> print flatten(m.plain())
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    <BLANKLINE>
+    check 1 2
+    check 1 2
+    <BLANKLINE>
+    >>> signed = m.sign(header)
+    >>> signed.set_boundary('boundsep')
+    >>> print flatten(signed).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    Content-Type: multipart/signed; protocol="application/pgp-signature";
+        micalg="pgp-sha1"; boundary="boundsep"
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    <BLANKLINE>
+    --boundsep
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Disposition: inline
+    <BLANKLINE>
+    check 1 2
+    check 1 2
+    <BLANKLINE>
+    --boundsep
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Description: signature
+    Content-Type: application/pgp-signature; name="signature.asc";
+        charset="us-ascii"
+    <BLANKLINE>
+    -----BEGIN PGP SIGNATURE-----
+    ...
+    -----END PGP SIGNATURE-----
+    <BLANKLINE>
+    --boundsep--
+    >>> encrypted = m.encrypt(header)
+    >>> encrypted.set_boundary('boundsep')
+    >>> print flatten(encrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    Content-Type: multipart/encrypted;
+        protocol="application/pgp-encrypted";
+        micalg="pgp-sha1"; boundary="boundsep"
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    <BLANKLINE>
+    --boundsep
+    Content-Type: application/pgp-encrypted
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    <BLANKLINE>
+    Version: 1
+    <BLANKLINE>
+    --boundsep
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Type: application/octet-stream; charset="us-ascii"
+    <BLANKLINE>
+    -----BEGIN PGP MESSAGE-----
+    ...
+    -----END PGP MESSAGE-----
+    <BLANKLINE>
+    --boundsep--
+    >>> signedAndEncrypted = m.signAndEncrypt(header)
+    >>> signedAndEncrypted.set_boundary('boundsep')
+    >>> print flatten(signedAndEncrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    Content-Type: multipart/encrypted;
+        protocol="application/pgp-encrypted";
+        micalg="pgp-sha1"; boundary="boundsep"
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    <BLANKLINE>
+    --boundsep
+    Content-Type: application/pgp-encrypted
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    <BLANKLINE>
+    Version: 1
+    <BLANKLINE>
+    --boundsep
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Type: application/octet-stream; charset="us-ascii"
+    <BLANKLINE>
+    -----BEGIN PGP MESSAGE-----
+    ...
+    -----END PGP MESSAGE-----
+    <BLANKLINE>
+    --boundsep--
+    """
+    def __init__(self, body):
+        self.body = body
+    def clearBodyPart(self):
+        body = encodedMIMEText(self.body)
+        body.add_header('Content-Disposition', 'inline')
+        return body
+    def passphrase_arg(self, passphrase=None):
+        if passphrase == None and PASSPHRASE != None:
+            passphrase = PASSPHRASE
+        if passphrase == None:
+            return (None,'')
+        return (passphrase, pgp_stdin_passphrase_arg)
+    def plain(self):
+        """
+        text/plain
+        """
+        return encodedMIMEText(self.body)
+    def sign(self, header, passphrase=None):
+        """
+        multipart/signed
+          +-> text/plain                 (body)
+          +-> application/pgp-signature  (signature)
+        """
+        passphrase,pass_arg = self.passphrase_arg(passphrase)
+        body = self.clearBodyPart()
+        bfile = tempfile.NamedTemporaryFile()
+        bfile.write(flatten(body))
+        bfile.flush()
+
+        args = replace(pgp_sign_command, 'f', bfile.name)
+        if PGP_SIGN_AS == None:
+            pgp_sign_as = '<%s>' % source_email(header)
+        else:
+            pgp_sign_as = PGP_SIGN_AS
+        args = replace(args, 'a', pgp_sign_as)
+        args = replace(args, 'p', pass_arg)
+        status,output,error = execute(args, stdin=passphrase)
+        signature = output
+
+        sig = MIMEApplication(_data=signature,
+                              _subtype='pgp-signature; name="signature.asc"',
+                              _encoder=encode_7or8bit)
+        sig['Content-Description'] = 'signature'
+        sig.set_charset('us-ascii')
+
+        msg = MIMEMultipart('signed', micalg='pgp-sha1',
+                            protocol='application/pgp-signature')
+        msg.attach(body)
+        msg.attach(sig)
+
+        msg['Content-Disposition'] = 'inline'
+        return msg
+    def encrypt(self, header, passphrase=None):
+        """
+        multipart/encrypted
+         +-> application/pgp-encrypted  (control information)
+         +-> application/octet-stream   (body)
+        """
+        body = self.clearBodyPart()
+        bfile = tempfile.NamedTemporaryFile()
+        bfile.write(flatten(body))
+        bfile.flush()
+
+        recipients = [replace(pgp_recipient_arg, 'r', recipient)
+                      for recipient in target_emails(header)]
+        recipient_string = ' '.join(recipients)
+        args = replace(pgp_encrypt_only_command, 'R', recipient_string)
+        args = replace(args, 'f', bfile.name)
+        if PGP_SIGN_AS == None:
+            pgp_sign_as = '<%s>' % source_email(header)
+        else:
+            pgp_sign_as = PGP_SIGN_AS
+        args = replace(args, 'a', pgp_sign_as)
+        status,output,error = execute(args)
+        encrypted = output
+
+        enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
+                              _encoder=encode_7or8bit)
+        enc.set_charset('us-ascii')
+
+        control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted',
+                                  _encoder=encode_7or8bit)
+
+        msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
+                            protocol='application/pgp-encrypted')
+        msg.attach(control)
+        msg.attach(enc)
+
+        msg['Content-Disposition'] = 'inline'
+        return msg
+    def signAndEncrypt(self, header, passphrase=None):
+        """
+        multipart/encrypted
+         +-> application/pgp-encrypted  (control information)
+         +-> application/octet-stream   (body)
+        """
+        passphrase,pass_arg = self.passphrase_arg(passphrase)
+        body = self.sign(header, passphrase)
+        body.__delitem__('Bcc')
+        bfile = tempfile.NamedTemporaryFile()
+        bfile.write(flatten(body))
+        bfile.flush()
+
+        recipients = [replace(pgp_recipient_arg, 'r', recipient)
+                      for recipient in target_emails(header)]
+        recipient_string = ' '.join(recipients)
+        args = replace(pgp_encrypt_only_command, 'R', recipient_string)
+        args = replace(args, 'f', bfile.name)
+        if PGP_SIGN_AS == None:
+            pgp_sign_as = '<%s>' % source_email(header)
+        else:
+            pgp_sign_as = PGP_SIGN_AS
+        args = replace(args, 'a', pgp_sign_as)
+        args = replace(args, 'p', pass_arg)
+        status,output,error = execute(args, stdin=passphrase)
+        encrypted = output
+
+        enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
+                              _encoder=encode_7or8bit)
+        enc.set_charset('us-ascii')
+
+        control = MIMEApplication(_data='Version: 1\n',
+                                  _subtype='pgp-encrypted',
+                                  _encoder=encode_7or8bit)
+
+        msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
+                            protocol='application/pgp-encrypted')
+        msg.attach(control)
+        msg.attach(enc)
+
+        msg['Content-Disposition'] = 'inline'
+        return msg
+
+def test():
+    import doctest
+    doctest.testmod()
+
+
+if __name__ == '__main__':
+    from optparse import OptionParser
+
+    parser = OptionParser(usage=usage)
+    parser.add_option('-t', '--test', dest='test', action='store_true',
+                      help='Run doctests and exit')
+
+    parser.add_option('-H', '--header-file', dest='header_filename',
+                      help='file containing email header', metavar='FILE')
+    parser.add_option('-B', '--body-file', dest='body_filename',
+                      help='file containing email body', metavar='FILE')
+
+    parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
+                      help='file containing gpg passphrase', metavar='FILE')
+    parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
+                      help='file descriptor from which to read gpg passphrase (0 for stdin)',
+                      type="int", metavar='DESCRIPTOR')
+
+    parser.add_option('--mode', dest='mode', default='sign',
+                      help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'.  Defaults to %default.",
+                      metavar='MODE')
+
+    parser.add_option('-a', '--sign-as', dest='sign_as',
+                      help="The gpg key to sign with (gpg's -u/--local-user)",
+                      metavar='KEY')
+
+    parser.add_option('--output', dest='output', action='store_true',
+                      help="Don't mail the generated message, print it to stdout instead.")
+
+    (options, args) = parser.parse_args()
+
+    stdin_used = False
+
+    if options.passphrase_file != None:
+        PASSPHRASE = file(options.passphrase_file, 'r').read()
+    elif options.passphrase_fd != None:
+        if options.passphrase_fd == 0:
+            stdin_used = True
+            PASSPHRASE = sys.stdin.read()
+        else:
+            PASSPHRASE = os.read(options.passphrase_fd)
+
+    if options.sign_as:
+        PGP_SIGN_AS = options.sign_as
+
+    if options.test == True:
+        test()
+        sys.exit(0)
+
+    header = None
+    if options.header_filename != None:
+        if options.header_filename == '-':
+            assert stdin_used == False
+            stdin_used = True
+            header = sys.stdin.read()
+        else:
+            header = file(options.header_filename, 'r').read()
+    if header == None:
+        raise Exception, "missing header"
+    headermsg = header_from_text(header)
+    body = None
+    if options.body_filename != None:
+        if options.body_filename == '-':
+            assert stdin_used == False
+            stdin_used = True
+            body = sys.stdin.read()
+        else:
+            body = file(options.body_filename, 'r').read()
+    if body == None:
+        raise Exception, "missing body"
+
+    m = PGPMimeMessageFactory(body)
+    if options.mode == "sign":
+        bodymsg = m.sign(header)
+    elif options.mode == "encrypt":
+        bodymsg = m.encrypt(header)
+    elif options.mode == "sign-encrypt":
+        bodymsg = m.signAndEncrypt(header)
+    elif options.mode == "plain":
+        bodymsg = m.plain()
+    else:
+        print "Unrecognized mode '%s'" % options.mode
+
+    message = attach_root(headermsg, bodymsg)
+    if options.output == True:
+        message = flatten(message)
+        print message
+    else:
+        mail(message, sendmail)
index a0d0ff9e432066923127a4b6f8fc28c5f3e55bf7..50cc754ce7914bbbb34bb465321a67a579528824 100644 (file)
@@ -119,9 +119,9 @@ class Bug(PrestHandler):
             assigned = None
         bug.assigned = assigned
         bug.save()
-#        bug.rcs.precommit(bug.path)
-#        bug.rcs.commit(bug.path, "Auto-commit")
-#        bug.rcs.postcommit(bug.path)
+#        bug.vcs.precommit(bug.path)
+#        bug.vcs.commit(bug.path, "Auto-commit")
+#        bug.vcs.postcommit(bug.path)
         raise cherrypy.HTTPRedirect(bug_list_url(bug_data["project"]))
 
     def instantiate(self, project, bug):
index 2f45aa924fa497ff23ef0e84051be6bc89eb536f..ab551729da670de122e9de31f91bd155710a095b 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+GNU Arch (tla) backend.
+"""
+
 import codecs
 import os
 import re
@@ -26,10 +30,11 @@ import time
 import unittest
 import doctest
 
-import config
 from beuuid import uuid_gen
-import rcs
-from rcs import RCS
+import config
+import vcs
+
+
 
 DEFAULT_CLIENT = "tla"
 
@@ -38,7 +43,7 @@ client = config.get_val("arch_client", default=DEFAULT_CLIENT)
 def new():
     return Arch()
 
-class Arch(RCS):
+class Arch(vcs.VCS):
     name = "Arch"
     client = client
     versioned = True
@@ -48,21 +53,25 @@ class Arch(RCS):
     _project_name = None
     _tmp_project = False
     _arch_paramdir = os.path.expanduser("~/.arch-params")
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         """Detect whether a directory is revision-controlled using Arch"""
         if self._u_search_parent_directories(path, "{arch}") != None :
             config.set_val("arch_client", client)
             return True
         return False
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._create_archive(path)
         self._create_project(path)
         self._add_project_code(path)
     def _create_archive(self, path):
-        # Create a new archive
+        """
+        Create a temporary Arch archive in the directory PATH.  This
+        archive will be removed by
+          __del__->cleanup->_vcs_cleanup->_remove_archive
+        """
         # http://regexps.srparish.net/tutorial-tla/new-archive.html#Creating_a_New_Archive
         assert self._archive_name == None
         id = self.get_user_id()
@@ -103,7 +112,7 @@ class Arch(RCS):
         """
         Create a temporary Arch project in the directory PATH.  This
         project will be removed by
-          __del__->cleanup->_rcs_cleanup->_remove_project
+          __del__->cleanup->_vcs_cleanup->_remove_project
         """
         # http://mwolson.org/projects/GettingStartedWithArch.html
         # http://regexps.srparish.net/tutorial-tla/new-project.html#Starting_a_New_Project
@@ -159,13 +168,13 @@ class Arch(RCS):
         self._adjust_naming_conventions(path)
         self._invoke_client("import", "--summary", "Began versioning",
                             directory=path)
-    def _rcs_cleanup(self):
+    def _vcs_cleanup(self):
         if self._tmp_project == True:
             self._remove_project()
         if self._tmp_archive == True:
             self._remove_archive()
 
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         if not os.path.isdir(path):
             dirname = os.path.dirname(path)
         else:
@@ -176,7 +185,6 @@ class Arch(RCS):
         self._get_archive_project_name(root)
 
         return root
-
     def _get_archive_name(self, root):
         status,output,error = self._u_invoke_client("archives")
         lines = output.split('\n')
@@ -188,7 +196,6 @@ class Arch(RCS):
             if os.path.realpath(location) == os.path.realpath(root):
                 self._archive_name = archive
         assert self._archive_name != None
-
     def _get_archive_project_name(self, root):
         # get project names
         status,output,error = self._u_invoke_client("tree-version", directory=root)
@@ -197,7 +204,7 @@ class Arch(RCS):
         archive_name,project_name = output.rstrip('\n').split('/')
         self._archive_name = archive_name
         self._project_name = project_name
-    def _rcs_get_user_id(self):
+    def _vcs_get_user_id(self):
         try:
             status,output,error = self._u_invoke_client('my-id')
             return output.rstrip('\n')
@@ -206,9 +213,9 @@ class Arch(RCS):
                 return None
             else:
                 raise
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         self._u_invoke_client('my-id', value)
-    def _rcs_add(self, path):
+    def _vcs_add(self, path):
         self._u_invoke_client("add-id", path)
         realpath = os.path.realpath(self._u_abspath(path))
         pathAdded = realpath in self._list_added(self.rootdir)
@@ -237,14 +244,14 @@ class Arch(RCS):
         self._add_dir_rule(rule, os.path.dirname(path), self.rootdir)
         if os.path.realpath(path) not in self._list_added(self.rootdir):
             raise CantAddFile(path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         if not '.arch-ids' in path:
             self._u_invoke_client("delete-id", path)
-    def _rcs_update(self, path):
+    def _vcs_update(self, path):
         pass
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision, binary=binary)
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
         else:
             status,output,error = \
                 self._invoke_client("file-find", path, revision)
@@ -254,18 +261,18 @@ class Arch(RCS):
             contents = f.read()
             f.close()
             return contents
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision == None:
-            RCS._rcs_duplicate_repo(self, directory, revision)
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             status,output,error = \
                 self._u_invoke_client("get", revision,directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         if allow_empty == False:
             # arch applies empty commits without complaining, so check first
             status,output,error = self._u_invoke_client("changes",expect=(0,1))
             if status == 0:
-                raise rcs.EmptyCommit()
+                raise vcs.EmptyCommit()
         summary,body = self._u_parse_commitfile(commitfile)
         args = ["commit", "--summary", summary]
         if body != None:
@@ -281,6 +288,16 @@ class Arch(RCS):
         assert revpath.startswith(self._archive_project_name()+'--')
         revision = revpath[len(self._archive_project_name()+'--'):]
         return revpath
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("logs")
+        logs = output.splitlines()
+        first_log = logs.pop(0)
+        assert first_log == "base-0", first_log
+        try:
+            log = logs[index]
+        except IndexError:
+            return None
+        return "%s--%s" % (self._archive_project_name(), log)
 
 class CantAddFile(Exception):
     def __init__(self, file):
@@ -289,7 +306,7 @@ class CantAddFile(Exception):
 
 
 \f
-rcs.make_rcs_testcase_subclasses(Arch, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Arch, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index bc47208210a88f7b0d65a64d9a4cb8033a4eba71..490ed6243044ceb65e766dae31abae4a499619aa 100644 (file)
@@ -13,6 +13,7 @@
 # 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.
+
 """
 Backwards compatibility support for Python 2.4.  Once people give up
 on 2.4 ;), the uuid call should be merged into bugdir.py
@@ -20,6 +21,7 @@ on 2.4 ;), the uuid call should be merged into bugdir.py
 
 import unittest
 
+
 try:
     from uuid import uuid4 # Python >= 2.5
     def uuid_gen():
index c1e54815434e802f27b69ca8deeeec114ebd9840..fd30ff74a74e67c38285273f366755ef0e3f1f14 100644 (file)
 # 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.
+
+"""
+Define the Bug class for representing bugs.
+"""
+
 import os
 import os.path
 import errno
@@ -33,6 +38,11 @@ import comment
 import utility
 
 
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
+
 ### Define and describe valid bug categories
 # Use a tuple of (category, description) tuples since we don't have
 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
@@ -216,15 +226,15 @@ class Bug(settings_object.SavedSettingsObject):
     @doc_property(doc="The trunk of the comment tree")
     def comment_root(): return {}
 
-    def _get_rcs(self):
-        if hasattr(self.bugdir, "rcs"):
-            return self.bugdir.rcs
+    def _get_vcs(self):
+        if hasattr(self.bugdir, "vcs"):
+            return self.bugdir.vcs
 
     @Property
-    @cached_property(generator=_get_rcs)
-    @local_property("rcs")
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
     @doc_property(doc="A revision control system instance.")
-    def rcs(): return {}
+    def vcs(): return {}
 
     def __init__(self, bugdir=None, uuid=None, from_disk=False,
                  load_comments=False, summary=None):
@@ -238,17 +248,20 @@ class Bug(settings_object.SavedSettingsObject):
             if uuid == None:
                 self.uuid = uuid_gen()
             self.time = int(time.time()) # only save to second precision
-            if self.rcs != None:
-                self.creator = self.rcs.get_user_id()
+            if self.vcs != None:
+                self.creator = self.vcs.get_user_id()
             self.summary = summary
 
     def __repr__(self):
         return "Bug(uuid=%r)" % self.uuid
 
-    def set_sync_with_disk(self, value):
-        self.sync_with_disk = value
-        for comment in self.comments():
-            comment.set_sync_with_disk(value)
+    def __str__(self):
+        return self.string(shortlist=True)
+
+    def __cmp__(self, other):
+        return cmp_full(self, other)
+
+    # serializing methods
 
     def _setting_attr_string(self, setting):
         value = getattr(self, setting)
@@ -331,43 +344,34 @@ class Bug(settings_object.SavedSettingsObject):
             output = bugout
         return output
 
-    def __str__(self):
-        return self.string(shortlist=True)
+    # methods for saving/loading/acessing settings and properties.
 
-    def __cmp__(self, other):
-        return cmp_full(self, other)
+    def get_path(self, *args):
+        dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["values", "comments"], str(args)
+        return os.path.join(dir, *args)
 
-    def get_path(self, name=None):
-        my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
-        if name is None:
-            return my_dir
-        assert name in ["values", "comments"]
-        return os.path.join(my_dir, name)
+    def set_sync_with_disk(self, value):
+        self.sync_with_disk = value
+        for comment in self.comments():
+            comment.set_sync_with_disk(value)
 
     def load_settings(self):
-        self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load settings")
+        self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
         self._setup_saved_settings()
 
-    def load_comments(self, load_full=True):
-        if load_full == True:
-            # Force a complete load of the whole comment tree
-            self.comment_root = self._get_comment_root(load_full=True)
-        else:
-            # Setup for fresh lazy-loading.  Clear _comment_root, so
-            # _get_comment_root returns a fresh version.  Turn of
-            # syncing temporarily so we don't write our blank comment
-            # tree to disk.
-            self.sync_with_disk = False
-            self.comment_root = None
-            self.sync_with_disk = True
-
     def save_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("save settings")
         assert self.summary != None, "Can't save blank bug"
-        
-        self.rcs.mkdir(self.get_path())
+        self.vcs.mkdir(self.get_path())
         path = self.get_path("values")
-        mapfile.map_save(self.rcs, path, self._get_saved_settings())
-        
+        mapfile.map_save(self.vcs, path, self._get_saved_settings())
+
     def save(self):
         """
         Save any loaded contents to disk.  Because of lazy loading of
@@ -378,15 +382,39 @@ class Bug(settings_object.SavedSettingsObject):
         calling this method will just waste time (unless something
         else has been messing with your on-disk files).
         """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
         self.save_settings()
         if len(self.comment_root) > 0:
             comment.saveComments(self)
+        if sync_with_disk == False:
+            self.set_sync_with_disk(False)
+
+    def load_comments(self, load_full=True):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load comments")
+        if load_full == True:
+            # Force a complete load of the whole comment tree
+            self.comment_root = self._get_comment_root(load_full=True)
+        else:
+            # Setup for fresh lazy-loading.  Clear _comment_root, so
+            # _get_comment_root returns a fresh version.  Turn of
+            # syncing temporarily so we don't write our blank comment
+            # tree to disk.
+            self.sync_with_disk = False
+            self.comment_root = None
+            self.sync_with_disk = True
 
     def remove(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("remove")
         self.comment_root.remove()
         path = self.get_path()
-        self.rcs.recursive_remove(path)
+        self.vcs.recursive_remove(path)
     
+    # methods for managing comments
+
     def comments(self):
         for comment in self.comment_root.traverse():
             yield comment
@@ -489,8 +517,12 @@ def cmp_attr(bug_1, bug_2, attr, invert=False):
         return cmp(val_1, val_2)
 
 # alphabetical rankings (a < z)
+cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
+cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
+cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
+cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
 # chronological rankings (newer < older)
 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
 
@@ -512,7 +544,8 @@ def cmp_comments(bug_1, bug_2):
     return 0
 
 DEFAULT_CMP_FULL_CMP_LIST = \
-    (cmp_status,cmp_severity,cmp_assigned,cmp_time,cmp_creator,cmp_comments)
+    (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
+     cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid)
 
 class BugCompoundComparator (object):
     def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
index 6e020ee784bf21f4363cd9d6aa0c7517c437fd9c..c4f0f9192b327035f90c8a88689f60a6044d6ccb 100644 (file)
 # 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.
+
+"""
+Define the BugDir class for representing bug comments.
+"""
+
+import copy
+import errno
 import os
 import os.path
-import errno
+import sys
 import time
-import copy
 import unittest
 import doctest
 
+import bug
+import encoding
 from properties import Property, doc_property, local_property, \
     defaulting_property, checked_property, fn_checked_property, \
     cached_property, primed_property, change_hook_property, \
     settings_property
-import settings_object
 import mapfile
-import bug
-import rcs
-import encoding
+import vcs
+import settings_object
+import upgrade
 import utility
 
 
@@ -62,8 +69,16 @@ class MultipleBugMatches(ValueError):
         self.shortname = shortname
         self.matches = matches
 
+class NoBugMatches(KeyError):
+    def __init__(self, shortname):
+        msg = "No bug matches %s" % shortname
+        KeyError.__init__(self, msg)
+        self.shortname = shortname
 
-TREE_VERSION_STRING = "Bugs Everywhere Tree 1 0\n"
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
 
 
 class BugDir (list, settings_object.SavedSettingsObject):
@@ -99,7 +114,8 @@ class BugDir (list, settings_object.SavedSettingsObject):
     all bugs/comments/etc. that have been loaded into memory.  If
     you've been living in memory and want to move to
     .sync_with_disk==True, but you're not sure if anything has been
-    changed in memoryy, a call to save() is a safe move.
+    changed in memory, a call to save() immediately before the
+    .set_sync_with_disk(True) call is a safe move.
 
     Regardless of .sync_with_disk, a call to .save() will write out
     all the contents that the BugDir instance has loaded into memory.
@@ -107,15 +123,14 @@ class BugDir (list, settings_object.SavedSettingsObject):
     changes, this .save() call will be a waste of time.
 
     The BugDir will only load information from the file system when it
-    loads new bugs/comments that it doesn't already have in memory, or
-    when it explicitly asked to do so (e.g. .load() or
-    __init__(from_disk=True)).
+    loads new settings/bugs/comments that it doesn't already have in
+    memory and .sync_with_disk == True.
 
-    Allow RCS initialization
+    Allow VCS initialization
     ========================
 
     This one is for testing purposes.  Setting it to True allows the
-    BugDir to search for an installed RCS backend and initialize it in
+    BugDir to search for an installed VCS backend and initialize it in
     the root directory.  This is a convenience option for supporting
     tests of versioning functionality (e.g. .duplicate_bugdir).
 
@@ -172,9 +187,9 @@ class BugDir (list, settings_object.SavedSettingsObject):
     def encoding(): return {}
 
     def _setup_user_id(self, user_id):
-        self.rcs.user_id = user_id
+        self.vcs.user_id = user_id
     def _guess_user_id(self):
-        return self.rcs.get_user_id()
+        return self.vcs.get_user_id()
     def _set_user_id(self, old_user_id, new_user_id):
         self._setup_user_id(new_user_id)
         self._prop_save_settings(old_user_id, new_user_id)
@@ -182,7 +197,7 @@ class BugDir (list, settings_object.SavedSettingsObject):
     @_versioned_property(name="user_id",
                          doc=
 """The user's prefered name, e.g. 'John Doe <jdoe@example.com>'.  Note
-that the Arch RCS backend *enforces* ids with this format.""",
+that the Arch VCS backend *enforces* ids with this format.""",
                          change_hook=_set_user_id,
                          generator=_guess_user_id)
     def user_id(): return {}
@@ -192,32 +207,32 @@ that the Arch RCS backend *enforces* ids with this format.""",
 """The default assignee for new bugs e.g. 'John Doe <jdoe@example.com>'.""")
     def default_assignee(): return {}
 
-    @_versioned_property(name="rcs_name",
-                         doc="""The name of the current RCS.  Kept seperate to make saving/loading
-settings easy.  Don't set this attribute.  Set .rcs instead, and
-.rcs_name will be automatically adjusted.""",
+    @_versioned_property(name="vcs_name",
+                         doc="""The name of the current VCS.  Kept seperate to make saving/loading
+settings easy.  Don't set this attribute.  Set .vcs instead, and
+.vcs_name will be automatically adjusted.""",
                          default="None",
                          allowed=["None", "Arch", "bzr", "darcs", "git", "hg"])
-    def rcs_name(): return {}
+    def vcs_name(): return {}
 
-    def _get_rcs(self, rcs_name=None):
+    def _get_vcs(self, vcs_name=None):
         """Get and root a new revision control system"""
-        if rcs_name == None:
-            rcs_name = self.rcs_name
-        new_rcs = rcs.rcs_by_name(rcs_name)
-        self._change_rcs(None, new_rcs)
-        return new_rcs
-    def _change_rcs(self, old_rcs, new_rcs):
-        new_rcs.encoding = self.encoding
-        new_rcs.root(self.root)
-        self.rcs_name = new_rcs.name
+        if vcs_name == None:
+            vcs_name = self.vcs_name
+        new_vcs = vcs.vcs_by_name(vcs_name)
+        self._change_vcs(None, new_vcs)
+        return new_vcs
+    def _change_vcs(self, old_vcs, new_vcs):
+        new_vcs.encoding = self.encoding
+        new_vcs.root(self.root)
+        self.vcs_name = new_vcs.name
 
     @Property
-    @change_hook_property(hook=_change_rcs)
-    @cached_property(generator=_get_rcs)
-    @local_property("rcs")
+    @change_hook_property(hook=_change_vcs)
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
     @doc_property(doc="A revision control system instance.")
-    def rcs(): return {}
+    def vcs(): return {}
 
     def _bug_map_gen(self):
         map = {}
@@ -279,9 +294,8 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
 
 
     def __init__(self, root=None, sink_to_existing_root=True,
-                 assert_new_BugDir=False, allow_rcs_init=False,
-                 manipulate_encodings=True,
-                 from_disk=False, rcs=None):
+                 assert_new_BugDir=False, allow_vcs_init=False,
+                 manipulate_encodings=True, from_disk=False, vcs=None):
         list.__init__(self)
         settings_object.SavedSettingsObject.__init__(self)
         self._manipulate_encodings = manipulate_encodings
@@ -293,9 +307,9 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
             if not os.path.exists(root):
                 raise NoRootEntry(root)
             self.root = root
-        # get a temporary rcs until we've loaded settings
+        # get a temporary vcs until we've loaded settings
         self.sync_with_disk = False
-        self.rcs = self._guess_rcs()
+        self.vcs = self._guess_vcs()
 
         if from_disk == True:
             self.sync_with_disk = True
@@ -305,20 +319,24 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
             if assert_new_BugDir == True:
                 if os.path.exists(self.get_path()):
                     raise AlreadyInitialized, self.get_path()
-            if rcs == None:
-                rcs = self._guess_rcs(allow_rcs_init)
-            self.rcs = rcs
+            if vcs == None:
+                vcs = self._guess_vcs(allow_vcs_init)
+            self.vcs = vcs
             self._setup_user_id(self.user_id)
 
-    def set_sync_with_disk(self, value):
-        self.sync_with_disk = value
-        for bug in self:
-            bug.set_sync_with_disk(value)
+    def __del__(self):
+        self.cleanup()
+
+    def cleanup(self):
+        self.vcs.cleanup()
+
+    # methods for getting the BugDir situated in the filesystem
 
     def _find_root(self, path):
         """
         Search for an existing bug database dir and it's ancestors and
-        return a BugDir rooted there.
+        return a BugDir rooted there.  Only called by __init__, and
+        then only if sink_to_existing_root == True.
         """
         if not os.path.exists(path):
             raise NoRootEntry(path)
@@ -334,136 +352,212 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
                 raise NoBugDir(path)
             return beroot
 
-    def get_version(self, path=None, use_none_rcs=False):
-        if use_none_rcs == True:
-            RCS = rcs.rcs_by_name("None")
-            RCS.root(self.root)
-            RCS.encoding = encoding.get_encoding()
+    def _guess_vcs(self, allow_vcs_init=False):
+        """
+        Only called by __init__.
+        """
+        deepdir = self.get_path()
+        if not os.path.exists(deepdir):
+            deepdir = os.path.dirname(deepdir)
+        new_vcs = vcs.detect_vcs(deepdir)
+        install = False
+        if new_vcs.name == "None":
+            if allow_vcs_init == True:
+                new_vcs = vcs.installed_vcs()
+                new_vcs.init(self.root)
+        return new_vcs
+
+    # methods for saving/loading/accessing settings and properties.
+
+    def get_path(self, *args):
+        """
+        Return a path relative to .root.
+        """
+        dir = os.path.join(self.root, ".be")
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["version", "settings", "bugs"], str(args)
+        return os.path.join(dir, *args)
+
+    def _get_settings(self, settings_path, for_duplicate_bugdir=False):
+        allow_no_vcs = not self.vcs.path_in_root(settings_path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        if self.sync_with_disk == False and for_duplicate_bugdir == False:
+            # duplicates can ignore this bugdir's .sync_with_disk status
+            raise DiskAccessRequired("_get settings")
+        try:
+            settings = mapfile.map_load(self.vcs, settings_path, allow_no_vcs)
+        except vcs.NoSuchFile:
+            settings = {"vcs_name": "None"}
+        return settings
+
+    def _save_settings(self, settings_path, settings,
+                       for_duplicate_bugdir=False):
+        allow_no_vcs = not self.vcs.path_in_root(settings_path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        if self.sync_with_disk == False and for_duplicate_bugdir == False:
+            # duplicates can ignore this bugdir's .sync_with_disk status
+            raise DiskAccessRequired("_save settings")
+        self.vcs.mkdir(self.get_path(), allow_no_vcs)
+        mapfile.map_save(self.vcs, settings_path, settings, allow_no_vcs)
+
+    def load_settings(self):
+        self.settings = self._get_settings(self.get_path("settings"))
+        self._setup_saved_settings()
+        self._setup_user_id(self.user_id)
+        self._setup_encoding(self.encoding)
+        self._setup_severities(self.severities)
+        self._setup_status(self.active_status, self.inactive_status)
+        self.vcs = vcs.vcs_by_name(self.vcs_name)
+        self._setup_user_id(self.user_id)
+
+    def save_settings(self):
+        settings = self._get_saved_settings()
+        self._save_settings(self.get_path("settings"), settings)
+
+    def get_version(self, path=None, use_none_vcs=False,
+                    for_duplicate_bugdir=False):
+        """
+        Requires disk access.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("get version")
+        if use_none_vcs == True:
+            VCS = vcs.vcs_by_name("None")
+            VCS.root(self.root)
+            VCS.encoding = encoding.get_encoding()
         else:
-            RCS = self.rcs
+            VCS = self.vcs
 
         if path == None:
             path = self.get_path("version")
-        tree_version = RCS.get_file_contents(path)
-        return tree_version
+        allow_no_vcs = not VCS.path_in_root(path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        version = VCS.get_file_contents(
+            path, allow_no_vcs=allow_no_vcs).rstrip("\n")
+        return version
 
     def set_version(self):
-        self.rcs.mkdir(self.get_path())
-        self.rcs.set_file_contents(self.get_path("version"),
-                                   TREE_VERSION_STRING)
+        """
+        Requires disk access.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("set version")
+        self.vcs.mkdir(self.get_path())
+        self.vcs.set_file_contents(self.get_path("version"),
+                                   upgrade.BUGDIR_DISK_VERSION+"\n")
 
-    def get_path(self, *args):
-        my_dir = os.path.join(self.root, ".be")
-        if len(args) == 0:
-            return my_dir
-        assert args[0] in ["version", "settings", "bugs"], str(args)
-        return os.path.join(my_dir, *args)
+    # methods controlling disk access
 
-    def _guess_rcs(self, allow_rcs_init=False):
-        deepdir = self.get_path()
-        if not os.path.exists(deepdir):
-            deepdir = os.path.dirname(deepdir)
-        new_rcs = rcs.detect_rcs(deepdir)
-        install = False
-        if new_rcs.name == "None":
-            if allow_rcs_init == True:
-                new_rcs = rcs.installed_rcs()
-                new_rcs.init(self.root)
-        return new_rcs
+    def set_sync_with_disk(self, value):
+        """
+        Adjust .sync_with_disk for the BugDir and all it's children.
+        See the BugDir docstring for a description of the role of
+        .sync_with_disk.
+        """
+        self.sync_with_disk = value
+        for bug in self:
+            bug.set_sync_with_disk(value)
 
     def load(self):
-        version = self.get_version(use_none_rcs=True)
-        if version != TREE_VERSION_STRING:
-            raise NotImplementedError, \
-                "BugDir cannot handle version '%s' yet." % version
+        """
+        Reqires disk access
+        """
+        version = self.get_version(use_none_vcs=True)
+        if version != upgrade.BUGDIR_DISK_VERSION:
+            upgrade.upgrade(self.root, version)
         else:
             if not os.path.exists(self.get_path()):
                 raise NoBugDir(self.get_path())
             self.load_settings()
 
-            self.rcs = rcs.rcs_by_name(self.rcs_name)
-            self._setup_user_id(self.user_id)
-
     def load_all_bugs(self):
-        "Warning: this could take a while."
+        """
+        Requires disk access.
+        Warning: this could take a while.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load all bugs")
         self._clear_bugs()
         for uuid in self.list_uuids():
             self._load_bug(uuid)
 
     def save(self):
         """
+        Note that this command writes to disk _regardless_ of the
+        status of .sync_with_disk.
+
         Save any loaded contents to disk.  Because of lazy loading of
         bugs and comments, this is actually not too inefficient.
 
-        However, if self.sync_with_disk = True, then any changes are
+        However, if .sync_with_disk = True, then any changes are
         automatically written to disk as soon as they happen, so
         calling this method will just waste time (unless something
         else has been messing with your on-disk files).
+
+        Requires disk access.
         """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
         self.set_version()
         self.save_settings()
         for bug in self:
             bug.save()
+        if sync_with_disk == False:
+            self.set_sync_with_disk(sync_with_disk)
 
-    def load_settings(self):
-        self.settings = self._get_settings(self.get_path("settings"))
-        self._setup_saved_settings()
-        self._setup_user_id(self.user_id)
-        self._setup_encoding(self.encoding)
-        self._setup_severities(self.severities)
-        self._setup_status(self.active_status, self.inactive_status)
+    # methods for managing duplicate BugDirs
 
-    def _get_settings(self, settings_path):
-        allow_no_rcs = not self.rcs.path_in_root(settings_path)
-        # allow_no_rcs=True should only be for the special case of
-        # configuring duplicate bugdir settings
+    def duplicate_bugdir(self, revision):
+        duplicate_path = self.vcs.duplicate_repo(revision)
 
+        duplicate_version_path = os.path.join(duplicate_path, ".be", "version")
         try:
-            settings = mapfile.map_load(self.rcs, settings_path, allow_no_rcs)
-        except rcs.NoSuchFile:
-            settings = {"rcs_name": "None"}
-        return settings
-
-    def save_settings(self):
-        settings = self._get_saved_settings()
-        self._save_settings(self.get_path("settings"), settings)
-
-    def _save_settings(self, settings_path, settings):
-        allow_no_rcs = not self.rcs.path_in_root(settings_path)
-        # allow_no_rcs=True should only be for the special case of
-        # configuring duplicate bugdir settings
-        self.rcs.mkdir(self.get_path(), allow_no_rcs)
-        mapfile.map_save(self.rcs, settings_path, settings, allow_no_rcs)
-
-    def duplicate_bugdir(self, revision):
-        duplicate_path = self.rcs.duplicate_repo(revision)
+            version = self.get_version(duplicate_version_path,
+                                       for_duplicate_bugdir=True)
+        except DiskAccessRequired:
+            self.sync_with_disk = True # temporarily allow access
+            version = self.get_version(duplicate_version_path,
+                                       for_duplicate_bugdir=True)
+            self.sync_with_disk = False
+        if version != upgrade.BUGDIR_DISK_VERSION:
+            upgrade.upgrade(duplicate_path, version)
 
-        # setup revision RCS as None, since the duplicate may not be
+        # setup revision VCS as None, since the duplicate may not be
         # initialized for versioning
         duplicate_settings_path = os.path.join(duplicate_path,
                                                ".be", "settings")
-        duplicate_settings = self._get_settings(duplicate_settings_path)
-        if "rcs_name" in duplicate_settings:
-            duplicate_settings["rcs_name"] = "None"
+        duplicate_settings = self._get_settings(duplicate_settings_path,
+                                                for_duplicate_bugdir=True)
+        if "vcs_name" in duplicate_settings:
+            duplicate_settings["vcs_name"] = "None"
             duplicate_settings["user_id"] = self.user_id
         if "disabled" in bug.status_values:
             # Hack to support old versions of BE bugs
             duplicate_settings["inactive_status"] = self.inactive_status
-        self._save_settings(duplicate_settings_path, duplicate_settings)
+        self._save_settings(duplicate_settings_path, duplicate_settings,
+                            for_duplicate_bugdir=True)
 
         return BugDir(duplicate_path, from_disk=True, manipulate_encodings=self._manipulate_encodings)
 
     def remove_duplicate_bugdir(self):
-        self.rcs.remove_duplicate_repo()
+        self.vcs.remove_duplicate_repo()
+
+    # methods for managing bugs
 
     def list_uuids(self):
         uuids = []
-        if os.path.exists(self.get_path()):
+        if self.sync_with_disk == True and os.path.exists(self.get_path()):
             # list the uuids on disk
-            for uuid in os.listdir(self.get_path("bugs")):
-                if not (uuid.startswith('.')):
-                    uuids.append(uuid)
-                    yield uuid
+            if os.path.exists(self.get_path("bugs")):
+                for uuid in os.listdir(self.get_path("bugs")):
+                    if not (uuid.startswith('.')):
+                        uuids.append(uuid)
+                        yield uuid
         # and the ones that are still just in memory
         for bug in self:
             if bug.uuid not in uuids:
@@ -476,6 +570,8 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
         self._bug_map_gen()
 
     def _load_bug(self, uuid):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("_load bug")
         bg = bug.Bug(bugdir=self, uuid=uuid, from_disk=True)
         self.append(bg)
         self._bug_map_gen()
@@ -492,7 +588,8 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
 
     def remove_bug(self, bug):
         self.remove(bug)
-        bug.remove()
+        if bug.sync_with_disk == True:
+            bug.remove()
 
     def bug_shortname(self, bug):
         """
@@ -514,12 +611,13 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
 
     def bug_from_shortname(self, shortname):
         """
-        >>> bd = simple_bug_dir()
+        >>> bd = SimpleBugDir(sync_with_disk=False)
         >>> bug_a = bd.bug_from_shortname('a')
         >>> print type(bug_a)
         <class 'libbe.bug.Bug'>
         >>> print bug_a
         a:om: Bug A
+        >>> bd.cleanup()
         """
         matches = []
         self._bug_map_gen()
@@ -530,7 +628,7 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
             raise MultipleBugMatches(shortname, matches)
         if len(matches) == 1:
             return self.bug_from_uuid(matches[0])
-        raise KeyError("No bug matches %s" % shortname)
+        raise NoBugMatches(shortname)
 
     def bug_from_uuid(self, uuid):
         if not self.has_bug(uuid):
@@ -548,41 +646,56 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
         return True
 
 
-def simple_bug_dir():
+class SimpleBugDir (BugDir):
     """
-    For testing
-    >>> bugdir = simple_bug_dir()
-    >>> ls = list(bugdir.list_uuids())
-    >>> ls.sort()
-    >>> print ls
+    For testing.  Set sync_with_disk==False for a memory-only bugdir.
+    >>> bugdir = SimpleBugDir()
+    >>> uuids = list(bugdir.list_uuids())
+    >>> uuids.sort()
+    >>> print uuids
     ['a', 'b']
+    >>> bugdir.cleanup()
     """
-    dir = utility.Dir()
-    assert os.path.exists(dir.path)
-    bugdir = BugDir(dir.path, sink_to_existing_root=False, allow_rcs_init=True,
+    def __init__(self, sync_with_disk=True):
+        if sync_with_disk == True:
+            dir = utility.Dir()
+            assert os.path.exists(dir.path)
+            root = dir.path
+            assert_new_BugDir = True
+            vcs_init = True
+        else:
+            root = "/"
+            assert_new_BugDir = False
+            vcs_init = False
+        BugDir.__init__(self, root, sink_to_existing_root=False,
+                    assert_new_BugDir=assert_new_BugDir,
+                    allow_vcs_init=vcs_init,
                     manipulate_encodings=False)
-    bugdir._dir_ref = dir # postpone cleanup since dir.__del__() removes dir.
-    bug_a = bugdir.new_bug("a", summary="Bug A")
-    bug_a.creator = "John Doe <jdoe@example.com>"
-    bug_a.time = 0
-    bug_b = bugdir.new_bug("b", summary="Bug B")
-    bug_b.creator = "Jane Doe <jdoe@example.com>"
-    bug_b.time = 0
-    bug_b.status = "closed"
-    bugdir.save()
-    return bugdir
-
+        if sync_with_disk == True: # postpone cleanup since dir.__del__() removes dir.
+            self._dir_ref = dir
+        bug_a = self.new_bug("a", summary="Bug A")
+        bug_a.creator = "John Doe <jdoe@example.com>"
+        bug_a.time = 0
+        bug_b = self.new_bug("b", summary="Bug B")
+        bug_b.creator = "Jane Doe <jdoe@example.com>"
+        bug_b.time = 0
+        bug_b.status = "closed"
+        if sync_with_disk == True:
+            self.save()
+            self.set_sync_with_disk(True)
+    def cleanup(self):
+        if hasattr(self, "_dir_ref"):
+            self._dir_ref.cleanup()
+        BugDir.cleanup(self)
 
 class BugDirTestCase(unittest.TestCase):
-    def __init__(self, *args, **kwargs):
-        unittest.TestCase.__init__(self, *args, **kwargs)
     def setUp(self):
         self.dir = utility.Dir()
         self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
-                             allow_rcs_init=True)
-        self.rcs = self.bugdir.rcs
+                             allow_vcs_init=True)
+        self.vcs = self.bugdir.vcs
     def tearDown(self):
-        self.rcs.cleanup()
+        self.bugdir.cleanup()
         self.dir.cleanup()
     def fullPath(self, path):
         return os.path.join(self.dir.path, path)
@@ -593,13 +706,13 @@ class BugDirTestCase(unittest.TestCase):
         self.assertRaises(AlreadyInitialized, BugDir,
                           self.dir.path, assertNewBugDir=True)
     def versionTest(self):
-        if self.rcs.versioned == False:
+        if self.vcs.versioned == False:
             return
-        original = self.bugdir.rcs.commit("Began versioning")
+        original = self.bugdir.vcs.commit("Began versioning")
         bugA = self.bugdir.bug_from_uuid("a")
         bugA.status = "fixed"
         self.bugdir.save()
-        new = self.rcs.commit("Fixed bug a")
+        new = self.vcs.commit("Fixed bug a")
         dupdir = self.bugdir.duplicate_bugdir(original)
         self.failUnless(dupdir.root != self.bugdir.root,
                         "%s, %s" % (dupdir.root, self.bugdir.root))
@@ -645,17 +758,19 @@ class BugDirTestCase(unittest.TestCase):
         rep.new_reply("And they have six legs.")
         if sync_with_disk == False:
             self.bugdir.save()
+            self.bugdir.set_sync_with_disk(True)
         self.bugdir._clear_bugs()
         bug = self.bugdir.bug_from_uuid("a")
         bug.load_comments()
+        if sync_with_disk == False:
+            self.bugdir.set_sync_with_disk(False)
         self.failUnless(len(bug.comment_root)==1, len(bug.comment_root))
         for index,comment in enumerate(bug.comments()):
             if index == 0:
                 repLoaded = comment
                 self.failUnless(repLoaded.uuid == rep.uuid, repLoaded.uuid)
-                self.failUnless(comment.sync_with_disk == True,
+                self.failUnless(comment.sync_with_disk == sync_with_disk,
                                 comment.sync_with_disk)
-                #load_settings()
                 self.failUnless(comment.content_type == "text/plain",
                                 comment.content_type)
                 self.failUnless(repLoaded.settings["Content-type"]=="text/plain",
@@ -672,5 +787,46 @@ class BugDirTestCase(unittest.TestCase):
     def testSyncedComments(self):
         self.testComments(sync_with_disk=True)
 
-unitsuite = unittest.TestLoader().loadTestsFromTestCase(BugDirTestCase)
-suite = unittest.TestSuite([unitsuite])#, doctest.DocTestSuite()])
+class SimpleBugDirTestCase (unittest.TestCase):
+    def setUp(self):
+        # create a pre-existing bugdir in a temporary directory
+        self.dir = utility.Dir()
+        self.original_working_dir = os.getcwd()
+        os.chdir(self.dir.path)
+        self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
+                             allow_vcs_init=True)
+        self.bugdir.new_bug("preexisting", summary="Hopefully not imported")
+        self.bugdir.save()
+    def tearDown(self):
+        os.chdir(self.original_working_dir)
+        self.bugdir.cleanup()
+        self.dir.cleanup()
+    def testOnDiskCleanLoad(self):
+        """SimpleBugDir(sync_with_disk==True) should not import preexisting bugs."""
+        bugdir = SimpleBugDir(sync_with_disk=True)
+        self.failUnless(bugdir.sync_with_disk==True, bugdir.sync_with_disk)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir._clear_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == [], uuids)
+        bugdir.load_all_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir.cleanup()
+    def testInMemoryCleanLoad(self):
+        """SimpleBugDir(sync_with_disk==False) should not import preexisting bugs."""
+        bugdir = SimpleBugDir(sync_with_disk=False)
+        self.failUnless(bugdir.sync_with_disk==False, bugdir.sync_with_disk)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        self.failUnlessRaises(DiskAccessRequired, bugdir.load_all_bugs)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir._clear_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == [], uuids)
+        bugdir.cleanup()
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index d7cd1e5693b1b6248d4885aa615a6ae947262de9..e9e0649615ef86a0c618228fa091f3f42c1f2221 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Bazaar (bzr) backend.
+"""
+
 import os
 import re
 import sys
 import unittest
 import doctest
 
-import rcs
-from rcs import RCS
+import vcs
+
 
 def new():
     return Bzr()
 
-class Bzr(RCS):
+class Bzr(vcs.VCS):
     name = "bzr"
     client = "bzr"
     versioned = True
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output        
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         if self._u_search_parent_directories(path, ".bzr") != None :
             return True
         return False
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         """Find the root of the deepest repository containing path."""
         status,output,error = self._u_invoke_client("root", path)
         return output.rstrip('\n')
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._u_invoke_client("init", directory=path)
-    def _rcs_get_user_id(self):
+    def _vcs_get_user_id(self):
         status,output,error = self._u_invoke_client("whoami")
         return output.rstrip('\n')
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         self._u_invoke_client("whoami", value)
-    def _rcs_add(self, path):
+    def _vcs_add(self, path):
         self._u_invoke_client("add", path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         # --force to also remove unversioned files.
         self._u_invoke_client("remove", "--force", path)
-    def _rcs_update(self, path):
+    def _vcs_update(self, path):
         pass
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision, binary=binary)
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
         else:
             status,output,error = \
                 self._u_invoke_client("cat","-r",revision,path)
             return output
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision == None:
-            RCS._rcs_duplicate_repo(self, directory, revision)
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             self._u_invoke_client("branch", "--revision", revision,
                                   ".", directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         args = ["commit", "--file", commitfile]
         if allow_empty == True:
             args.append("--unchanged")
@@ -83,9 +87,9 @@ class Bzr(RCS):
                 strings = ["ERROR: no changes to commit.", # bzr 1.3.1
                            "ERROR: No changes to commit."] # bzr 1.15.1
                 if self._u_any_in_string(strings, error) == True:
-                    raise rcs.EmptyCommit()
+                    raise vcs.EmptyCommit()
                 else:
-                    raise rcs.CommandError(args, status, error)
+                    raise vcs.CommandError(args, status, stdout="", stderr=error)
         revision = None
         revline = re.compile("Committed revision (.*)[.]")
         match = revline.search(error)
@@ -93,9 +97,17 @@ class Bzr(RCS):
         assert len(match.groups()) == 1
         revision = match.groups()[0]
         return revision
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("revno")
+        current_revision = int(output)
+        if index >= current_revision or index < -current_revision:
+            return None
+        if index >= 0:
+            return str(index+1) # bzr commit 0 is the empty tree.
+        return str(current_revision+index+1)
 
 \f    
-rcs.make_rcs_testcase_subclasses(Bzr, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Bzr, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index 853a75a406f53fa863a9263ad5ff12566f5df41a..9b6414259750565523f98a6bfa5f5ae0b380a28a 100644 (file)
 # 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.
+
+"""
+Define assorted utilities to make command-line handling easier.
+"""
+
 import glob
 import optparse
 import os
@@ -70,10 +75,11 @@ def get_command(command_name):
     return cmd
 
 
-def execute(cmd, args):
+def execute(cmd, args, manipulate_encodings=True):
     enc = encoding.get_encoding()
     cmd = get_command(cmd)
-    ret = cmd.execute([a.decode(enc) for a in args])
+    ret = cmd.execute([a.decode(enc) for a in args],
+                      manipulate_encodings=manipulate_encodings)
     if ret == None:
         ret = 0
     return ret
@@ -206,6 +212,15 @@ def underlined(instring):
     
     return "%s\n%s" % (instring, "="*len(instring))
 
+def bug_from_shortname(bdir, shortname):
+    """
+    Exception translation for the command-line interface.
+    """
+    try:
+        bug = bdir.bug_from_shortname(shortname)
+    except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e:
+        raise UserError(e.message)
+    return bug
 
 def _test():
     import doctest
index 3249e8bdc6ba1e63592ebed2f41b91185b7145a2..41bc7e6c1ea629277e8836186ad570ab676ea22a 100644 (file)
 # 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.
+
+"""
+Define the Comment class for representing bug comments.
+"""
+
 import base64
 import os
 import os.path
@@ -61,6 +66,11 @@ class MissingReference(ValueError):
         self.reference = comment.in_reply_to
         self.comment = comment
 
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
+
 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
 
 def list_to_root(comments, bug, root=None,
@@ -115,8 +125,10 @@ def loadComments(bug, load_full=False):
     Set load_full=True when you want to load the comment completely
     from disk *now*, rather than waiting and lazy loading as required.
     """
+    if bug.sync_with_disk == False:
+        raise DiskAccessRequired("load comments")
     path = bug.get_path("comments")
-    if not os.path.isdir(path):
+    if not os.path.exists(path):
         return Comment(bug, uuid=INVALID_UUID)
     comments = []
     for uuid in os.listdir(path):
@@ -131,6 +143,8 @@ def loadComments(bug, load_full=False):
     return list_to_root(comments, bug)
 
 def saveComments(bug):
+    if bug.sync_with_disk == False:
+        raise DiskAccessRequired("save comments")
     for comment in bug.comment_root.traverse():
         comment.save()
 
@@ -162,9 +176,9 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                          doc="Alternate ID for linking imported comments.  Internally comments are linked (via In-reply-to) to the parent's UUID.  However, these UUIDs are generated internally, so Alt-id is provided as a user-controlled linking target.")
     def alt_id(): return {}
 
-    @_versioned_property(name="From",
+    @_versioned_property(name="Author",
                          doc="The author of the comment")
-    def From(): return {}
+    def author(): return {}
 
     @_versioned_property(name="In-reply-to",
                          doc="UUID for parent comment or bug")
@@ -178,28 +192,28 @@ class Comment(Tree, settings_object.SavedSettingsObject):
 
     @_versioned_property(name="Date",
                          doc="An RFC 2822 timestamp for comment creation")
-    def time_string(): return {}
+    def date(): return {}
 
     def _get_time(self):
-        if self.time_string == None:
+        if self.date == None:
             return None
-        return utility.str_to_time(self.time_string)
+        return utility.str_to_time(self.date)
     def _set_time(self, value):
-        self.time_string = utility.time_to_str(value)
+        self.date = utility.time_to_str(value)
     time = property(fget=_get_time,
                     fset=_set_time,
-                    doc="An integer version of .time_string")
+                    doc="An integer version of .date")
 
     def _get_comment_body(self):
-        if self.rcs != None and self.sync_with_disk == True:
-            import rcs
+        if self.vcs != None and self.sync_with_disk == True:
+            import vcs
             binary = not self.content_type.startswith("text/")
-            return self.rcs.get_file_contents(self.get_path("body"), binary=binary)
+            return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
     def _set_comment_body(self, old=None, new=None, force=False):
-        if (self.rcs != None and self.sync_with_disk == True) or force==True:
+        if (self.vcs != None and self.sync_with_disk == True) or force==True:
             assert new != None, "Can't save empty comment"
             binary = not self.content_type.startswith("text/")
-            self.rcs.set_file_contents(self.get_path("body"), new, binary=binary)
+            self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
 
     @Property
     @change_hook_property(hook=_set_comment_body)
@@ -208,15 +222,15 @@ class Comment(Tree, settings_object.SavedSettingsObject):
     @doc_property(doc="The meat of the comment")
     def body(): return {}
 
-    def _get_rcs(self):
-        if hasattr(self.bug, "rcs"):
-            return self.bug.rcs
+    def _get_vcs(self):
+        if hasattr(self.bug, "vcs"):
+            return self.bug.vcs
 
     @Property
-    @cached_property(generator=_get_rcs)
-    @local_property("rcs")
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
     @doc_property(doc="A revision control system instance.")
-    def rcs(): return {}
+    def vcs(): return {}
 
     def _extra_strings_check_fn(value):
         return utility.iterable_full_of_strings(value, \
@@ -257,13 +271,29 @@ class Comment(Tree, settings_object.SavedSettingsObject):
             if uuid == None:
                 self.uuid = uuid_gen()
             self.time = int(time.time()) # only save to second precision
-            if self.rcs != None:
-                self.From = self.rcs.get_user_id()
+            if self.vcs != None:
+                self.author = self.vcs.get_user_id()
             self.in_reply_to = in_reply_to
             self.body = body
 
-    def set_sync_with_disk(self, value):
-        self.sync_with_disk = True
+    def __cmp__(self, other):
+        return cmp_full(self, other)
+
+    def __str__(self):
+        """
+        >>> comm = Comment(bug=None, body="Some insightful remarks")
+        >>> comm.uuid = "com-1"
+        >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
+        >>> comm.author = "Jane Doe <jdoe@example.com>"
+        >>> print comm
+        --------- Comment ---------
+        Name: com-1
+        From: Jane Doe <jdoe@example.com>
+        Date: Thu, 20 Nov 2008 15:55:11 +0000
+        <BLANKLINE>
+        Some insightful remarks
+        """
+        return self.string()
 
     def traverse(self, *args, **kwargs):
         """Avoid working with the possible dummy root comment"""
@@ -272,6 +302,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 continue
             yield comment
 
+    # serializing methods
+
     def _setting_attr_string(self, setting):
         value = getattr(self, setting)
         if value == None:
@@ -282,12 +314,12 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         """
         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
         >>> comm.uuid = "0123"
-        >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> print comm.xml(indent=2, shortname="com-1")
           <comment>
             <uuid>0123</uuid>
             <short-name>com-1</short-name>
-            <from></from>
+            <author></author>
             <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
             <content-type>text/plain</content-type>
             <body>Some
@@ -309,8 +341,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 ("alt-id", self.alt_id),
                 ("short-name", shortname),
                 ("in-reply-to", self.in_reply_to),
-                ("from", self._setting_attr_string("From")),
-                ("date", self.time_string),
+                ("author", self._setting_attr_string("author")),
+                ("date", self.date),
                 ("content-type", self.content_type),
                 ("body", body)]
         lines = ["<comment>"]
@@ -328,11 +360,11 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         <alt-id> fields.
         >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
         >>> commA.uuid = "0123"
-        >>> commA.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> xml = commA.xml(shortname="com-1")
         >>> commB = Comment()
         >>> commB.from_xml(xml)
-        >>> attrs=['uuid','alt_id','in_reply_to','From','time_string','content_type','body']
+        >>> attrs=['uuid','alt_id','in_reply_to','author','date','content_type','body']
         >>> for attr in attrs: # doctest: +ELLIPSIS
         ...     if getattr(commB, attr) != getattr(commA, attr):
         ...         estr = "Mismatch on %s: '%s' should be '%s'"
@@ -342,15 +374,15 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         Mismatch on alt_id: '0123' should be 'None'
         >>> print commB.alt_id
         0123
-        >>> commA.From
-        >>> commB.From
+        >>> commA.author
+        >>> commB.author
         """
         if type(xml_string) == types.UnicodeType:
             xml_string = xml_string.strip().encode("unicode_escape")
         comment = ElementTree.XML(xml_string)
         if comment.tag != "comment":
             raise InvalidXML(comment, "root element must be <comment>")
-        tags=['uuid','alt-id','in-reply-to','from','date','content-type','body']
+        tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
         uuid = None
         body = None
         for child in comment.getchildren():
@@ -368,10 +400,6 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 if child.tag == "body":
                     body = text
                     continue # don't set the bug's body yet.
-                elif child.tag == 'from':
-                    attr_name = "From"
-                elif child.tag == 'date':
-                    attr_name = 'time_string'
                 else:
                     attr_name = child.tag.replace('-','_')
                 setattr(self, attr_name, text)
@@ -389,7 +417,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
     def string(self, indent=0, shortname=None):
         """
         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
-        >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> print comm.string(indent=2, shortname="com-1")
           --------- Comment ---------
           Name: com-1
@@ -405,8 +433,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         lines = []
         lines.append("--------- Comment ---------")
         lines.append("Name: %s" % shortname)
-        lines.append("From: %s" % (self._setting_attr_string("From")))
-        lines.append("Date: %s" % self.time_string)
+        lines.append("From: %s" % (self._setting_attr_string("author")))
+        lines.append("Date: %s" % self.date)
         lines.append("")
         if self.content_type.startswith("text/"):
             lines.extend((self.body or "").splitlines())
@@ -417,78 +445,6 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         sep = '\n' + istring
         return istring + sep.join(lines).rstrip('\n')
 
-    def __str__(self):
-        """
-        >>> comm = Comment(bug=None, body="Some insightful remarks")
-        >>> comm.uuid = "com-1"
-        >>> comm.time_string = "Thu, 20 Nov 2008 15:55:11 +0000"
-        >>> comm.From = "Jane Doe <jdoe@example.com>"
-        >>> print comm
-        --------- Comment ---------
-        Name: com-1
-        From: Jane Doe <jdoe@example.com>
-        Date: Thu, 20 Nov 2008 15:55:11 +0000
-        <BLANKLINE>
-        Some insightful remarks
-        """
-        return self.string()
-
-    def get_path(self, name=None):
-        my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
-        if name is None:
-            return my_dir
-        assert name in ["values", "body"]
-        return os.path.join(my_dir, name)
-
-    def load_settings(self):
-        self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
-        self._setup_saved_settings()
-
-    def save_settings(self):
-        self.rcs.mkdir(self.get_path())
-        path = self.get_path("values")
-        mapfile.map_save(self.rcs, path, self._get_saved_settings())
-
-    def save(self):
-        """
-        Save any loaded contents to disk.
-        
-        However, if self.sync_with_disk = True, then any changes are
-        automatically written to disk as soon as they happen, so
-        calling this method will just waste time (unless something
-        else has been messing with your on-disk files).
-        """
-        assert self.body != None, "Can't save blank comment"
-        self.save_settings()
-        self._set_comment_body(new=self.body, force=True)
-
-    def remove(self):
-        for comment in self.traverse():
-            path = comment.get_path()
-            self.rcs.recursive_remove(path)
-
-    def add_reply(self, reply, allow_time_inversion=False):
-        if self.uuid != INVALID_UUID:
-            reply.in_reply_to = self.uuid
-        self.append(reply)
-        #raise Exception, "adding reply \n%s\n%s" % (self, reply)
-
-    def new_reply(self, body=None):
-        """
-        >>> comm = Comment(bug=None, body="Some insightful remarks")
-        >>> repA = comm.new_reply("Critique original comment")
-        >>> repB = repA.new_reply("Begin flamewar :p")
-        >>> repB.in_reply_to == repA.uuid
-        True
-        """
-        reply = Comment(self.bug, body=body)
-        if self.bug != None:
-            reply.set_sync_with_disk(self.bug.sync_with_disk)
-        if reply.sync_with_disk == True:
-            reply.save()
-        self.add_reply(reply)
-        return reply
-
     def string_thread(self, string_method_name="string", name_map={},
                       indent=0, flatten=True,
                       auto_name_map=False, bug_shortname=None):
@@ -506,7 +462,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
           name_map = {}
           for shortname,comment in comm.comment_shortnames(bug_shortname):
               name_map[comment.uuid] = shortname
-          comm.sort(key=lambda c : c.From) # your sort
+          comm.sort(key=lambda c : c.author) # your sort
           comm.string_thread(name_map=name_map)
 
         >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
@@ -593,6 +549,77 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                                   indent=indent, auto_name_map=auto_name_map,
                                   bug_shortname=bug_shortname)
 
+    # methods for saving/loading/acessing settings and properties.
+
+    def get_path(self, *args):
+        dir = os.path.join(self.bug.get_path("comments"), self.uuid)
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["values", "body"], str(args)
+        return os.path.join(dir, *args)
+
+    def set_sync_with_disk(self, value):
+        self.sync_with_disk = value
+
+    def load_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load settings")
+        self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
+        self._setup_saved_settings()
+
+    def save_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("save settings")
+        self.vcs.mkdir(self.get_path())
+        path = self.get_path("values")
+        mapfile.map_save(self.vcs, path, self._get_saved_settings())
+
+    def save(self):
+        """
+        Save any loaded contents to disk.
+        
+        However, if self.sync_with_disk = True, then any changes are
+        automatically written to disk as soon as they happen, so
+        calling this method will just waste time (unless something
+        else has been messing with your on-disk files).
+        """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
+        assert self.body != None, "Can't save blank comment"
+        self.save_settings()
+        self._set_comment_body(new=self.body, force=True)
+        if sync_with_disk == False:
+            self.set_sync_with_disk(False)
+
+    def remove(self):
+        if self.sync_with_disk == False and self.uuid != INVALID_UUID:
+            raise DiskAccessRequired("remove")
+        for comment in self.traverse():
+            path = comment.get_path()
+            self.vcs.recursive_remove(path)
+
+    def add_reply(self, reply, allow_time_inversion=False):
+        if self.uuid != INVALID_UUID:
+            reply.in_reply_to = self.uuid
+        self.append(reply)
+
+    def new_reply(self, body=None):
+        """
+        >>> comm = Comment(bug=None, body="Some insightful remarks")
+        >>> repA = comm.new_reply("Critique original comment")
+        >>> repB = repA.new_reply("Begin flamewar :p")
+        >>> repB.in_reply_to == repA.uuid
+        True
+        """
+        reply = Comment(self.bug, body=body)
+        if self.bug != None:
+            reply.set_sync_with_disk(self.bug.sync_with_disk)
+        if reply.sync_with_disk == True:
+            reply.save()
+        self.add_reply(reply)
+        return reply
+
     def comment_shortnames(self, bug_shortname=None):
         """
         Iterate through (id, comment) pairs, in time order.
@@ -659,4 +686,59 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 return comment
         raise KeyError(uuid)
 
+def cmp_attr(comment_1, comment_2, attr, invert=False):
+    """
+    Compare a general attribute between two comments using the conventional
+    comparison rule for that attribute type.  If invert == True, sort
+    *against* that convention.
+    >>> attr="author"
+    >>> commentA = Comment()
+    >>> commentB = Comment()
+    >>> commentA.author = "John Doe"
+    >>> commentB.author = "Jane Doe"
+    >>> cmp_attr(commentA, commentB, attr) > 0
+    True
+    >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
+    True
+    >>> commentB.author = "John Doe"
+    >>> cmp_attr(commentA, commentB, attr) == 0
+    True
+    """
+    if not hasattr(comment_2, attr) :
+        return 1
+    val_1 = getattr(comment_1, attr)
+    val_2 = getattr(comment_2, attr)
+    if val_1 == None: val_1 = None
+    if val_2 == None: val_2 = None
+    
+    if invert == True :
+        return -cmp(val_1, val_2)
+    else :
+        return cmp(val_1, val_2)
+
+# alphabetical rankings (a < z)
+cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
+cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
+cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
+cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
+cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
+# chronological rankings (newer < older)
+cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
+
+DEFAULT_CMP_FULL_CMP_LIST = \
+    (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
+     cmp_uuid)
+
+class CommentCompoundComparator (object):
+    def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
+        self.cmp_list = cmp_list
+    def __call__(self, comment_1, comment_2):
+        for comparison in self.cmp_list :
+            val = comparison(comment_1, comment_2)
+            if val != 0 :
+                return val
+        return 0
+        
+cmp_full = CommentCompoundComparator()
+
 suite = doctest.DocTestSuite()
index 5e343b939ca2d94f9bf4c7d01f7f94e498875d1d..fb5a0288fbd0b8a3faf491197e6e2000fd4ae662 100644 (file)
 # 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.
+
+"""
+Create, save, and load the per-user config file at path().
+"""
+
 import ConfigParser
 import codecs
 import locale
@@ -21,6 +26,7 @@ import os.path
 import sys
 import doctest
 
+
 default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
 
 def path():
index e7132c017cafe2834d11ca3925e8a0b6c4e34eb3..16005f2a71e065f2fbb8155920bf0fabea6632a9 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Darcs backend.
+"""
+
 import codecs
 import os
 import re
 import sys
-import unittest
+try: # import core module, Python >= 2.5
+    from xml.etree import ElementTree
+except ImportError: # look for non-core module
+    from elementtree import ElementTree
+from xml.sax.saxutils import unescape
 import doctest
+import unittest
+
+import vcs
 
-import rcs
-from rcs import RCS
 
 def new():
     return Darcs()
 
-class Darcs(RCS):
+class Darcs(vcs.VCS):
     name="darcs"
     client="darcs"
     versioned=True
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         if self._u_search_parent_directories(path, "_darcs") != None :
             return True
         return False 
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         """Find the root of the deepest repository containing path."""
         # Assume that nothing funny is going on; in particular, that we aren't
         # dealing with a bare repo.
@@ -48,9 +57,9 @@ class Darcs(RCS):
         if darcs_dir == None:
             return None
         return os.path.dirname(darcs_dir)
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._u_invoke_client("init", directory=path)
-    def _rcs_get_user_id(self):
+    def _vcs_get_user_id(self):
         # following http://darcs.net/manual/node4.html#SECTION00410030000000000000
         # as of June 29th, 2009
         if self.rootdir == None:
@@ -65,32 +74,32 @@ class Darcs(RCS):
             if env_variable in os.environ:
                 return os.environ[env_variable]
         return None
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         if self.rootdir == None:
             self.root(".")
             if self.rootdir == None:
-                raise rcs.SettingIDnotSupported
+                raise vcs.SettingIDnotSupported
         author_path = os.path.join(self.rootdir, "_darcs", "prefs", "author")
         f = codecs.open(author_path, "w", self.encoding)
         f.write(value)
         f.close()
-    def _rcs_add(self, path):
+    def _vcs_add(self, path):
         if os.path.isdir(path):
             return
         self._u_invoke_client("add", path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         if not os.path.isdir(self._u_abspath(path)):
             os.remove(os.path.join(self.rootdir, path)) # darcs notices removal
-    def _rcs_update(self, path):
+    def _vcs_update(self, path):
         pass # darcs notices changes
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision,
+            return vcs.VCS._vcs_get_file_contents(self, path, revision,
                                               binary=binary)
         else:
             try:
                 return self._u_invoke_client("show", "contents", "--patch", revision, path)
-            except rcs.CommandError:
+            except vcs.CommandError:
                 # Darcs versions < 2.0.0pre2 lack the "show contents" command
 
                 status,output,error = self._u_invoke_client("diff", "--unified",
@@ -113,7 +122,7 @@ class Darcs(RCS):
                 status,output,error = self._u_invoke(args, stdin=target_patch)
 
                 if os.path.exists(os.path.join(self.rootdir, path)) == True:
-                    contents = RCS._rcs_get_file_contents(self, path,
+                    contents = vcs.VCS._vcs_get_file_contents(self, path,
                                                           binary=binary)
                 else:
                     contents = ""
@@ -123,41 +132,53 @@ class Darcs(RCS):
                 status,output,error = self._u_invoke(args, stdin=target_patch)
                 args=["patch", path]
                 status,output,error = self._u_invoke(args, stdin=major_patch)
-                current_contents = RCS._rcs_get_file_contents(self, path,
+                current_contents = vcs.VCS._vcs_get_file_contents(self, path,
                                                               binary=binary)
                 return contents
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision==None:
-            RCS._rcs_duplicate_repo(self, directory, revision)
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             self._u_invoke_client("put", "--to-patch", revision, directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         id = self.get_user_id()
         if '@' not in id:
             id = "%s <%s@invalid.com>" % (id, id)
         args = ['record', '--all', '--author', id, '--logfile', commitfile]
         status,output,error = self._u_invoke_client(*args)
         empty_strings = ["No changes!"]
-        revision = None
         if self._u_any_in_string(empty_strings, output) == True:
             if allow_empty == False:
-                raise rcs.EmptyCommit()
-            else: # we need a extra call to get the current revision
-                args = ["changes", "--last=1", "--xml"]
-                status,output,error = self._u_invoke_client(*args)
-                revline = re.compile("[ \t]*<name>(.*)</name>")
-                # note that darcs does _not_ make an empty revision.
-                # this returns the last non-empty revision id...
+                raise vcs.EmptyCommit()
+            # note that darcs does _not_ make an empty revision.
+            # this returns the last non-empty revision id...
+            revision = self._vcs_revision_id(-1)
         else:
             revline = re.compile("Finished recording patch '(.*)'")
-        match = revline.search(output)
-        assert match != None, output+error
-        assert len(match.groups()) == 1
-        revision = match.groups()[0]
+            match = revline.search(output)
+            assert match != None, output+error
+            assert len(match.groups()) == 1
+            revision = match.groups()[0]
         return revision
-
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("changes", "--xml")
+        revisions = []
+        xml_str = output.encode("unicode_escape").replace(r"\n", "\n")
+        element = ElementTree.XML(xml_str)
+        assert element.tag == "changelog", element.tag
+        for patch in element.getchildren():
+            assert patch.tag == "patch", patch.tag
+            for child in patch.getchildren():
+                if child.tag == "name":
+                    text = unescape(unicode(child.text).decode("unicode_escape").strip())
+                    revisions.append(text)
+        revisions.reverse()
+        try:
+            return revisions[index]
+        except IndexError:
+            return None
 \f    
-rcs.make_rcs_testcase_subclasses(Darcs, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Darcs, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index ba48efc568d34d852b6bb51d2d028aad0465d119..9253a23a99e819fd3030ac6f30bf09f21bba9f81 100644 (file)
 # 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.
-"""Compare two bug trees"""
-from libbe import cmdutil, bugdir, bug
-from libbe.utility import time_to_str
+
+"""Compare two bug trees."""
+
+import difflib
 import doctest
 
-def bug_diffs(old_bugdir, new_bugdir):
-    added = []
-    removed = []
-    modified = []
-    for uuid in old_bugdir.list_uuids():
-        old_bug = old_bugdir.bug_from_uuid(uuid)
-        try:
-            new_bug = new_bugdir.bug_from_uuid(uuid)
-            old_bug.load_comments()
-            new_bug.load_comments()
-            if old_bug != new_bug:
-                modified.append((old_bug, new_bug))
-        except KeyError:
-            removed.append(old_bug)
-    for uuid in new_bugdir.list_uuids():
-        if not old_bugdir.has_bug(uuid):
-            new_bug = new_bugdir.bug_from_uuid(uuid)
-            added.append(new_bug)
-    return (removed, modified, added)
+from libbe import bugdir, bug, settings_object, tree
+from libbe.utility import time_to_str
 
-def diff_report(bug_diffs_data, old_bugdir, new_bugdir):
-    bugs_removed,bugs_modified,bugs_added = bug_diffs_data
-    def modified_cmp(left, right):
-        return bug.cmp_severity(left[1], right[1])
 
-    bugs_added.sort(bug.cmp_severity)
-    bugs_removed.sort(bug.cmp_severity)
-    bugs_modified.sort(modified_cmp)
-    lines = []
-    
-    if old_bugdir.settings != new_bugdir.settings:
-        bugdir_settings = sorted(new_bugdir.settings_properties)
-        bugdir_settings.remove("rcs_name") # tweaked by bugdir.duplicate_bugdir
-        change_list = change_lines(old_bugdir, new_bugdir, bugdir_settings)
-        if len(change_list) >  0:
-            lines.append("Modified bug directory:")
-            change_strings = ["%s: %s -> %s" % f for f in change_list]
-            lines.extend(change_strings)
-            lines.append("")
-    if len(bugs_added) > 0:
-        lines.append("New bug reports:")
-        for bg in bugs_added:
-            lines.extend(bg.string(shortlist=True).splitlines())
-        lines.append("")
-    if len(bugs_modified) > 0:
-        printed = False
-        for old_bug, new_bug in bugs_modified:
-            change_str = bug_changes(old_bug, new_bug)
-            if change_str is None:
-                continue
-            if not printed:
-                printed = True
-                lines.append("Modified bug reports:")
-            lines.extend(change_str.splitlines())
-        if printed == True:
-            lines.append("")
-    if len(bugs_removed) > 0:
-        lines.append("Removed bug reports:")
-        for bg in bugs_removed:
-            lines.extend(bg.string(shortlist=True).splitlines())
-        lines.append("")
-    
-    return "\n".join(lines).rstrip("\n")
+class DiffTree (tree.Tree):
+    """
+    A tree holding difference data for easy report generation.
+    >>> bugdir = DiffTree("bugdir")
+    >>> bdsettings = DiffTree("settings", data="target: None -> 1.0")
+    >>> bugdir.append(bdsettings)
+    >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
+    >>> bugdir.append(bugs)
+    >>> new = DiffTree("new", "new bugs: ABC, DEF")
+    >>> bugs.append(new)
+    >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
+    >>> bugs.append(rem)
+    >>> print bugdir.report_string()
+    target: None -> 1.0
+    bug-count: 5 -> 6
+      new bugs: ABC, DEF
+      removed bugs: RST, UVW
+    >>> print "\\n".join(bugdir.paths())
+    bugdir
+    bugdir/settings
+    bugdir/bugs
+    bugdir/bugs/new
+    bugdir/bugs/rem
+    >>> bugdir.child_by_path("/") == bugdir
+    True
+    >>> bugdir.child_by_path("/bugs") == bugs
+    True
+    >>> bugdir.child_by_path("/bugs/rem") == rem
+    True
+    >>> bugdir.child_by_path("bugdir") == bugdir
+    True
+    >>> bugdir.child_by_path("bugdir/") == bugdir
+    True
+    >>> bugdir.child_by_path("bugdir/bugs") == bugs
+    True
+    >>> bugdir.child_by_path("/bugs").masked = True
+    >>> print bugdir.report_string()
+    target: None -> 1.0
+    """
+    def __init__(self, name, data=None, data_part_fn=str,
+                 requires_children=False, masked=False):
+        tree.Tree.__init__(self)
+        self.name = name
+        self.data = data
+        self.data_part_fn = data_part_fn
+        self.requires_children = requires_children
+        self.masked = masked
+    def paths(self, parent_path=None):
+        paths = []
+        if parent_path == None:
+            path = self.name
+        else:
+            path = "%s/%s" % (parent_path, self.name)
+        paths.append(path)
+        for child in self:
+            paths.extend(child.paths(path))
+        return paths
+    def child_by_path(self, path):
+        if hasattr(path, "split"): # convert string path to a list of names
+            names = path.split("/")
+            if names[0] == "":
+                names[0] = self.name # replace root with self
+            if len(names) > 1 and names[-1] == "":
+                names = names[:-1] # strip empty tail
+        else: # it was already an array
+            names = path
+        assert len(names) > 0, path
+        if names[0] == self.name:
+            if len(names) == 1:
+                return self
+            for child in self:
+                if names[1] == child.name:
+                    return child.child_by_path(names[1:])
+        if len(names) == 1:
+            raise KeyError, "%s doesn't match '%s'" % (names, self.name)
+        raise KeyError, "%s points to child not in %s" % (names, [c.name for c in self])
+    def report_string(self):
+        return "\n".join(self.report())
+    def report(self, root=None, parent=None, depth=0):
+        if root == None:
+            root = self.make_root()
+        if self.masked == True:
+            return None
+        data_part = self.data_part(depth)
+        if self.requires_children == True and len(self) == 0:
+            pass
+        else:
+            self.join(root, parent, data_part)
+            if data_part != None:
+                depth += 1
+        for child in self:
+            child.report(root, self, depth)
+        return root
+    def make_root(self):
+        return []
+    def join(self, root, parent, data_part):
+        if data_part != None:
+            root.append(data_part)
+    def data_part(self, depth, indent=True):
+        if self.data == None:
+            return None
+        if hasattr(self, "_cached_data_part"):
+            return self._cached_data_part
+        data_part = self.data_part_fn(self.data)
+        if indent == True:
+            data_part_lines = data_part.splitlines()
+            indent = "  "*(depth)
+            line_sep = "\n"+indent
+            data_part = indent+line_sep.join(data_part_lines)
+        self._cached_data_part = data_part
+        return data_part
 
-def change_lines(old, new, attributes):
-    change_list = []    
-    for attr in attributes:
-        old_attr = getattr(old, attr)
-        new_attr = getattr(new, attr)
-        if old_attr != new_attr:
-            change_list.append((attr, old_attr, new_attr))
-    if len(change_list) >= 0:
-        return change_list
-    else:
+class Diff (object):
+    """
+    Difference tree generator for BugDirs.
+    >>> import copy
+    >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+    >>> bd.user_id = "John Doe <j@doe.com>"
+    >>> bd_new = copy.deepcopy(bd)
+    >>> bd_new.target = "1.0"
+    >>> a = bd_new.bug_from_uuid("a")
+    >>> rep = a.comment_root.new_reply("I'm closing this bug")
+    >>> rep.uuid = "acom"
+    >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+    >>> a.status = "closed"
+    >>> b = bd_new.bug_from_uuid("b")
+    >>> bd_new.remove_bug(b)
+    >>> c = bd_new.new_bug("c", "Bug C")
+    >>> d = Diff(bd, bd_new)
+    >>> r = d.report_tree()
+    >>> print "\\n".join(r.paths())
+    bugdir
+    bugdir/settings
+    bugdir/bugs
+    bugdir/bugs/new
+    bugdir/bugs/new/c
+    bugdir/bugs/rem
+    bugdir/bugs/rem/b
+    bugdir/bugs/mod
+    bugdir/bugs/mod/a
+    bugdir/bugs/mod/a/settings
+    bugdir/bugs/mod/a/comments
+    bugdir/bugs/mod/a/comments/new
+    bugdir/bugs/mod/a/comments/new/acom
+    bugdir/bugs/mod/a/comments/rem
+    bugdir/bugs/mod/a/comments/mod
+    >>> print r.report_string()
+    Changed bug directory settings:
+      target: None -> 1.0
+    New bugs:
+      c:om: Bug C
+    Removed bugs:
+      b:cm: Bug B
+    Modified bugs:
+      a:cm: Bug A
+        Changed bug settings:
+          status: open -> closed
+        New comments:
+          from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
+            I'm closing this bug...
+    >>> bd.cleanup()
+    """
+    def __init__(self, old_bugdir, new_bugdir):
+        self.old_bugdir = old_bugdir
+        self.new_bugdir = new_bugdir
+
+    # data assembly methods
+
+    def _changed_bugs(self):
+        """
+        Search for differences in all bugs between .old_bugdir and
+        .new_bugdir.  Returns
+          (added_bugs, modified_bugs, removed_bugs)
+        where added_bugs and removed_bugs are lists of added and
+        removed bugs respectively.  modified_bugs is a list of
+        (old_bug,new_bug) pairs.
+        """
+        if hasattr(self, "__changed_bugs"):
+            return self.__changed_bugs
+        added = []
+        removed = []
+        modified = []
+        for uuid in self.new_bugdir.list_uuids():
+            new_bug = self.new_bugdir.bug_from_uuid(uuid)
+            try:
+                old_bug = self.old_bugdir.bug_from_uuid(uuid)
+            except KeyError:
+                added.append(new_bug)
+            else:
+                if old_bug.sync_with_disk == True:
+                    old_bug.load_comments()
+                if new_bug.sync_with_disk == True:
+                    new_bug.load_comments()
+                if old_bug != new_bug:
+                    modified.append((old_bug, new_bug))
+        for uuid in self.old_bugdir.list_uuids():
+            if not self.new_bugdir.has_bug(uuid):
+                old_bug = self.old_bugdir.bug_from_uuid(uuid)
+                removed.append(old_bug)
+        added.sort()
+        removed.sort()
+        modified.sort(self._bug_modified_cmp)
+        self.__changed_bugs = (added, modified, removed)
+        return self.__changed_bugs
+    def _bug_modified_cmp(self, left, right):
+        return cmp(left[1], right[1])
+    def _changed_comments(self, old, new):
+        """
+        Search for differences in all loaded comments between the bugs
+        old and new.  Returns
+          (added_comments, modified_comments, removed_comments)
+        analogous to ._changed_bugs.
+        """
+        if hasattr(self, "__changed_comments"):
+            if new.uuid in self.__changed_comments:
+                return self.__changed_comments[new.uuid]
+        else:
+            self.__changed_comments = {}
+        added = []
+        removed = []
+        modified = []
+        old.comment_root.sort(key=lambda comm : comm.time)
+        new.comment_root.sort(key=lambda comm : comm.time)
+        old_comment_ids = [c.uuid for c in old.comments()]
+        new_comment_ids = [c.uuid for c in new.comments()]
+        for uuid in new_comment_ids:
+            new_comment = new.comment_from_uuid(uuid)
+            try:
+                old_comment = old.comment_from_uuid(uuid)
+            except KeyError:
+                added.append(new_comment)
+            else:
+                if old_comment != new_comment:
+                    modified.append((old_comment, new_comment))
+        for uuid in old_comment_ids:
+            if uuid not in new_comment_ids:
+                new_comment = new.comment_from_uuid(uuid)
+                removed.append(new_comment)
+        self.__changed_comments[new.uuid] = (added, modified, removed)
+        return self.__changed_comments[new.uuid]
+    def _attribute_changes(self, old, new, attributes):
+        """
+        Take two objects old and new, and compare the value of *.attr
+        for attr in the list attribute names.  Returns a list of
+          (attr_name, old_value, new_value)
+        tuples.
+        """
+        change_list = []
+        for attr in attributes:
+            old_value = getattr(old, attr)
+            new_value = getattr(new, attr)
+            if old_value != new_value:
+                change_list.append((attr, old_value, new_value))
+        if len(change_list) >= 0:
+            return change_list
         return None
+    def _settings_properties_attribute_changes(self, old, new,
+                                              hidden_properties=[]):
+        properties = sorted(new.settings_properties)
+        for p in hidden_properties:
+            properties.remove(p)
+        attributes = [settings_object.setting_name_to_attr_name(None, p)
+                      for p in properties]
+        return self._attribute_changes(old, new, attributes)
+    def _bugdir_attribute_changes(self):
+        return self._settings_properties_attribute_changes( \
+            self.old_bugdir, self.new_bugdir,
+            ["vcs_name"]) # tweaked by bugdir.duplicate_bugdir
+    def _bug_attribute_changes(self, old, new):
+        return self._settings_properties_attribute_changes(old, new)
+    def _comment_attribute_changes(self, old, new):
+        return self._settings_properties_attribute_changes(old, new)
 
-def bug_changes(old, new):
-    bug_settings = sorted(new.settings_properties)
-    change_list = change_lines(old, new, bug_settings)
-    change_strings = ["%s: %s -> %s" % f for f in change_list]
+    # report generation methods
 
-    old_comment_ids = [c.uuid for c in old.comments()]
-    new_comment_ids = [c.uuid for c in new.comments()]
-    for comment_id in new_comment_ids:
-        if comment_id not in old_comment_ids:
-            summary = comment_summary(new.comment_from_uuid(comment_id), "new")
-            change_strings.append(summary)
-    for comment_id in old_comment_ids:
-        if comment_id not in new_comment_ids:
-            summary = comment_summary(new.comment_from_uuid(comment_id),
-                                      "removed")
-            change_strings.append(summary)
+    def report_tree(self, diff_tree=DiffTree):
+        """
+        Pretty bare to make it easy to adjust to specific cases.  You
+        can pass in a DiffTree subclass via diff_tree to override the
+        default report assembly process.
+        """
+        if hasattr(self, "__report_tree"):
+            return self.__report_tree
+        bugdir_settings = sorted(self.new_bugdir.settings_properties)
+        bugdir_settings.remove("vcs_name") # tweaked by bugdir.duplicate_bugdir
+        root = diff_tree("bugdir")
+        bugdir_attribute_changes = self._bugdir_attribute_changes()
+        if len(bugdir_attribute_changes) > 0:
+            bugdir = diff_tree("settings", bugdir_attribute_changes,
+                               self.bugdir_attribute_change_string)
+            root.append(bugdir)
+        bug_root = diff_tree("bugs")
+        root.append(bug_root)
+        add,mod,rem = self._changed_bugs()
+        bnew = diff_tree("new", "New bugs:", requires_children=True)
+        bug_root.append(bnew)
+        for bug in add:
+            b = diff_tree(bug.uuid, bug, self.bug_add_string)
+            bnew.append(b)
+        brem = diff_tree("rem", "Removed bugs:", requires_children=True)
+        bug_root.append(brem)
+        for bug in rem:
+            b = diff_tree(bug.uuid, bug, self.bug_rem_string)
+            brem.append(b)
+        bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
+        bug_root.append(bmod)
+        for old,new in mod:
+            b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
+            bmod.append(b)
+            bug_attribute_changes = self._bug_attribute_changes(old, new)
+            if len(bug_attribute_changes) > 0:
+                bset = diff_tree("settings", bug_attribute_changes,
+                                 self.bug_attribute_change_string)
+                b.append(bset)
+            if old.summary != new.summary:
+                data = (old.summary, new.summary)
+                bsum = diff_tree("summary", data, self.bug_summary_change_string)
+                b.append(bsum)
+            cr = diff_tree("comments")
+            b.append(cr)
+            a,m,d = self._changed_comments(old, new)
+            cnew = diff_tree("new", "New comments:", requires_children=True)
+            for comment in a:
+                c = diff_tree(comment.uuid, comment, self.comment_add_string)
+                cnew.append(c)
+            crem = diff_tree("rem", "Removed comments:",requires_children=True)
+            for comment in d:
+                c = diff_tree(comment.uuid, comment, self.comment_rem_string)
+                crem.append(c)
+            cmod = diff_tree("mod","Modified comments:",requires_children=True)
+            for o,n in m:
+                c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
+                cmod.append(c)
+                comm_attribute_changes = self._comment_attribute_changes(o, n)
+                if len(comm_attribute_changes) > 0:
+                    cset = diff_tree("settings", comm_attribute_changes,
+                                     self.comment_attribute_change_string)
+                if o.body != n.body:
+                    data = (o.body, n.body)
+                    cbody = diff_tree("cbody", data,
+                                      self.comment_body_change_string)
+                    c.append(cbody)
+            cr.extend([cnew, crem, cmod])
+        self.__report_tree = root
+        return self.__report_tree
 
-    if len(change_strings) == 0:
-        return None
-    return "%s\n  %s" % (new.string(shortlist=True),
-                         "  \n".join(change_strings))
+    # change data -> string methods.
+    # Feel free to play with these in subclasses.
 
+    def attribute_change_string(self, attribute_changes, indent=0):
+        indent_string = "  "*indent
+        change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
+        for i,change_string in enumerate(change_strings):
+            change_strings[i] = indent_string+change_string
+        return u"\n".join(change_strings)
+    def bugdir_attribute_change_string(self, attribute_changes):
+        return "Changed bug directory settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def bug_attribute_change_string(self, attribute_changes):
+        return "Changed bug settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def comment_attribute_change_string(self, attribute_changes):
+        return "Changed comment settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def bug_add_string(self, bug):
+        return bug.string(shortlist=True)
+    def bug_rem_string(self, bug):
+        return bug.string(shortlist=True)
+    def bug_mod_string(self, bugs):
+        old_bug,new_bug = bugs
+        return new_bug.string(shortlist=True)
+    def bug_summary_change_string(self, summaries):
+        old_summary,new_summary = summaries
+        return "summary changed:\n  %s\n  %s" % (old_summary, new_summary)
+    def _comment_summary_string(self, comment):
+        return "from %s on %s" % (comment.author, time_to_str(comment.time))
+    def comment_add_string(self, comment):
+        summary = self._comment_summary_string(comment)
+        first_line = comment.body.splitlines()[0]
+        return "%s\n  %s..." % (summary, first_line)
+    def comment_rem_string(self, comment):
+        summary = self._comment_summary_string(comment)
+        first_line = comment.body.splitlines()[0]
+        return "%s\n  %s..." % (summary, first_line)
+    def comment_mod_string(self, comments):
+        old_comment,new_comment = comments
+        return self._comment_summary_string(new_comment)
+    def comment_body_change_string(self, bodies):
+        old_body,new_body = bodies
+        return difflib.unified_diff(old_body, new_body)
 
-def comment_summary(comment, status):
-    return "%8s comment from %s on %s" % (status, comment.From, 
-                                          time_to_str(comment.time))
 
 suite = doctest.DocTestSuite()
index 93144b82001aeb276424643c73d5f9ceaafaf45a..ec410061fd79b384e4b83165c538966cf88b49dd 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Define editor_string(), a function that invokes an editor to accept
+user-produced text as a string.
+"""
+
 import codecs
 import locale
 import os
@@ -22,6 +27,7 @@ import sys
 import tempfile
 import doctest
 
+
 default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
 
 comment_marker = u"== Anything below this line will be ignored\n"
@@ -62,7 +68,8 @@ def editor_string(comment=None, encoding=None):
     fhandle, fname = tempfile.mkstemp()
     try:
         if comment is not None:
-            os.write(fhandle, '\n'+comment_string(comment))
+            cstring = u'\n'+comment_string(comment)
+            os.write(fhandle, cstring.encode(encoding))
         os.close(fhandle)
         oldmtime = os.path.getmtime(fname)
         os.system("%s %s" % (editor, fname))
index d6036022be583e247d211e6da897ef5e60f08565..fd513b56331aac3f898153c57da9c1396caddcca 100644 (file)
 # 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.
+
+"""
+Support input/output/filesystem encodings (e.g. UTF-8).
+"""
+
 import codecs
 import locale
 import sys
 import doctest
 
+
+ENCODING = None # override get_encoding() output by setting this
+
 def get_encoding():
     """
     Guess a useful input/output/filesystem encoding...  Maybe we need
     seperate encodings for input/output and filesystem?  Hmm...
     """
+    if ENCODING != None:
+        return ENCODING
     encoding = locale.getpreferredencoding() or sys.getdefaultencoding()
     if sys.platform != 'win32' or sys.version_info[:2] > (2, 3):
         encoding = locale.getlocale(locale.LC_TIME)[1] or encoding
index 2f9ffa9500f97ba7bb0e8d4d50980b82b07bcc64..3abe3b816bd4bc5c63ed22f7578e7fe62391e416 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Git backend.
+"""
+
 import os
 import re
 import sys
 import unittest
 import doctest
 
-import rcs
-from rcs import RCS
+import vcs
+
 
 def new():
     return Git()
 
-class Git(RCS):
+class Git(vcs.VCS):
     name="git"
     client="git"
     versioned=True
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         if self._u_search_parent_directories(path, ".git") != None :
             return True
         return False 
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         """Find the root of the deepest repository containing path."""
         # Assume that nothing funny is going on; in particular, that we aren't
         # dealing with a bare repo.
@@ -50,13 +54,21 @@ class Git(RCS):
         gitdir = os.path.join(path, output.rstrip('\n'))
         dirname = os.path.abspath(os.path.dirname(gitdir))
         return dirname
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._u_invoke_client("init", directory=path)
-    def _rcs_get_user_id(self):
-        status,output,error = self._u_invoke_client("config", "user.name")
-        name = output.rstrip('\n')
-        status,output,error = self._u_invoke_client("config", "user.email")
-        email = output.rstrip('\n')
+    def _vcs_get_user_id(self):
+        status,output,error = \
+            self._u_invoke_client("config", "user.name", expect=(0,1))
+        if status == 0:
+            name = output.rstrip('\n')
+        else:
+            name = ""
+        status,output,error = \
+            self._u_invoke_client("config", "user.email", expect=(0,1))
+        if status == 0:
+            email = output.rstrip('\n')
+        else:
+            email = ""
         if name != "" or email != "": # got something!
             # guess missing info, if necessary
             if name == "":
@@ -65,35 +77,35 @@ class Git(RCS):
                 email = self._u_get_fallback_email()
             return self._u_create_id(name, email)
         return None # Git has no infomation
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         name,email = self._u_parse_id(value)
         if email != None:
             self._u_invoke_client("config", "user.email", email)
         self._u_invoke_client("config", "user.name", name)
-    def _rcs_add(self, path):
+    def _vcs_add(self, path):
         if os.path.isdir(path):
             return
         self._u_invoke_client("add", path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         if not os.path.isdir(self._u_abspath(path)):
             self._u_invoke_client("rm", "-f", path)
-    def _rcs_update(self, path):
-        self._rcs_add(path)
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_update(self, path):
+        self._vcs_add(path)
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision, binary=binary)
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
         else:
             arg = "%s:%s" % (revision,path)
             status,output,error = self._u_invoke_client("show", arg)
             return output
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision==None:
-            RCS._rcs_duplicate_repo(self, directory, revision)
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             #self._u_invoke_client("archive", revision, directory) # makes tarball
             self._u_invoke_client("clone", "--no-checkout",".",directory)
             self._u_invoke_client("checkout", revision, directory=directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         args = ['commit', '--all', '--file', commitfile]
         if allow_empty == True:
             args.append("--allow-empty")
@@ -104,17 +116,33 @@ class Git(RCS):
             strings = ["nothing to commit",
                        "nothing added to commit"]
             if self._u_any_in_string(strings, output) == True:
-                raise rcs.EmptyCommit()
+                raise vcs.EmptyCommit()
         revision = None
         revline = re.compile("(.*) (.*)[:\]] (.*)")
         match = revline.search(output)
         assert match != None, output+error
         assert len(match.groups()) == 3
         revision = match.groups()[1]
-        return revision
+        full_revision = self._vcs_revision_id(-1)
+        assert full_revision.startswith(revision), \
+            "Mismatched revisions:\n%s\n%s" % (revision, full_revision)
+        return full_revision
+    def _vcs_revision_id(self, index):
+        args = ["rev-list", "--first-parent", "--reverse", "HEAD"]
+        kwargs = {"expect":(0,128)}
+        status,output,error = self._u_invoke_client(*args, **kwargs)
+        if status == 128:
+            if error.startswith("fatal: ambiguous argument 'HEAD': unknown "):
+                return None
+            raise vcs.CommandError(args, status, stdout="", stderr=error)
+        commits = output.splitlines()
+        try:
+            return commits[index]
+        except IndexError:
+            return None
 
 \f    
-rcs.make_rcs_testcase_subclasses(Git, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Git, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index a20eeb5ca73f49e3daf7c4759c69cced8de37786..f8f8121dd9f63669e7bbfb7a592427ecc6c1105a 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Mercurial (hg) backend.
+"""
+
 import os
 import re
 import sys
 import unittest
 import doctest
 
-import rcs
-from rcs import RCS
+import vcs
+
 
 def new():
     return Hg()
 
-class Hg(RCS):
+class Hg(vcs.VCS):
     name="hg"
     client="hg"
     versioned=True
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         """Detect whether a directory is revision-controlled using Mercurial"""
         if self._u_search_parent_directories(path, ".hg") != None:
             return True
         return False
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         status,output,error = self._u_invoke_client("root", directory=path)
         return output.rstrip('\n')
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._u_invoke_client("init", directory=path)
-    def _rcs_get_user_id(self):
+    def _vcs_get_user_id(self):
         status,output,error = self._u_invoke_client("showconfig","ui.username")
         return output.rstrip('\n')
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         """
         Supported by the Config Extension, but that is not part of
         standard Mercurial.
         http://www.selenic.com/mercurial/wiki/index.cgi/ConfigExtension
         """
-        raise rcs.SettingIDnotSupported
-    def _rcs_add(self, path):
+        raise vcs.SettingIDnotSupported
+    def _vcs_add(self, path):
         self._u_invoke_client("add", path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         self._u_invoke_client("rm", "--force", path)
-    def _rcs_update(self, path):
+    def _vcs_update(self, path):
         pass
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision, binary=binary)
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
         else:
             status,output,error = \
                 self._u_invoke_client("cat","-r",revision,path)
             return output
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision == None:
-            return RCS._rcs_duplicate_repo(self, directory, revision)
+            return vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             self._u_invoke_client("archive", "--rev", revision, directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         args = ['commit', '--logfile', commitfile]
         status,output,error = self._u_invoke_client(*args)
         if allow_empty == False:
             strings = ["nothing changed"]
             if self._u_any_in_string(strings, output) == True:
-                raise rcs.EmptyCommit()
-        status,output,error = self._u_invoke_client('identify')
-        revision = None
-        revline = re.compile("(.*) tip")
-        match = revline.search(output)
-        assert match != None, output+error
-        assert len(match.groups()) == 1
-        revision = match.groups()[0]
-        return revision
+                raise vcs.EmptyCommit()
+        return self._vcs_revision_id(-1)
+    def _vcs_revision_id(self, index, style="id"):
+        args = ["identify", "--rev", str(int(index)), "--%s" % style]
+        kwargs = {"expect": (0,255)}
+        status,output,error = self._u_invoke_client(*args, **kwargs)
+        if status == 0:
+            id = output.strip()
+            if id == '000000000000':
+                return None # before initial commit.
+            return id
+        return None
 
 \f    
-rcs.make_rcs_testcase_subclasses(Hg, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Hg, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index b959d7608c8abec2d5c0644585b76d0b936c050c..4d696013a6c6a0fb591caa440c63233207a51f43 100644 (file)
 # 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 yaml
-import os.path
+
+"""
+Provide a means of saving and loading dictionaries of parameters.  The
+saved "mapfiles" should be clear, flat-text files, and allow easy merging of
+independent/conflicting changes.
+"""
+
 import errno
-import utility
+import os.path
+import yaml
 import doctest
 
+
 class IllegalKey(Exception):
     def __init__(self, key):
         Exception.__init__(self, 'Illegal key "%s"' % key)
@@ -95,33 +102,15 @@ def parse(contents):
     >>> dict["e"]
     'f'
     """
-    old_format = False
-    for line in contents.splitlines():
-        if len(line.split("=")) == 2:
-            old_format = True
-            break
-    if old_format: # translate to YAML.  Hack to deal with old BE bugs.
-        newlines = []
-        for line in contents.splitlines():
-            line = line.rstrip('\n')
-            if len(line) == 0:
-                continue
-            fields = line.split("=")
-            if len(fields) == 2:
-                key,value = fields
-                newlines.append('%s: "%s"' % (key, value.replace('"','\\"')))
-            else:
-                newlines.append(line)
-        contents = '\n'.join(newlines)
     return yaml.load(contents) or {}
 
-def map_save(rcs, path, map, allow_no_rcs=False):
+def map_save(vcs, path, map, allow_no_vcs=False):
     """Save the map as a mapfile to the specified path"""
     contents = generate(map)
-    rcs.set_file_contents(path, contents, allow_no_rcs)
+    vcs.set_file_contents(path, contents, allow_no_vcs)
 
-def map_load(rcs, path, allow_no_rcs=False):
-    contents = rcs.get_file_contents(path, allow_no_rcs=allow_no_rcs)
+def map_load(vcs, path, allow_no_vcs=False):
+    contents = vcs.get_file_contents(path, allow_no_vcs=allow_no_vcs)
     return parse(contents)
 
 suite = doctest.DocTestSuite()
index 0545fd796f12f3d79c9f981d562095b82640fa62..d593d69132b115e205b99da55ff5ed72a6f03b02 100644 (file)
 # 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.
+
+"""
+Allow simple listing and loading of the various becommands and libbe
+submodules (i.e. "plugins").
+"""
+
 import os
 import os.path
 import sys
index 144220b181d0d06df08062d773da1a6c7687df39..09dd20e0417de099434d038f6ba254a2ba457323 100644 (file)
@@ -160,10 +160,10 @@ def _get_cached_mutable_property(self, cacher_name, property_name, default=None)
     if (cacher_name, property_name) not in self._mutable_property_cache_copy:
         return default
     return self._mutable_property_cache_copy[(cacher_name, property_name)]
-def _cmp_cached_mutable_property(self, cacher_name, property_name, value):
+def _cmp_cached_mutable_property(self, cacher_name, property_name, value, default=None):
     _init_mutable_property_cache(self)
     if (cacher_name, property_name) not in self._mutable_property_cache_hash:
-        return 1 # any value > non-existant old hash
+        _set_cached_mutable_property(self, cacher_name, property_name, default)
     old_hash = self._mutable_property_cache_hash[(cacher_name, property_name)]
     return cmp(_hash_mutable_value(value), old_hash)
 
@@ -327,7 +327,7 @@ def primed_property(primer, initVal=None):
         return funcs
     return decorator
 
-def change_hook_property(hook, mutable=False):
+def change_hook_property(hook, mutable=False, default=None):
     """
     Call the function hook(instance, old_value, new_value) whenever a
     value different from the current value is set (instance is a a
@@ -359,9 +359,9 @@ def change_hook_property(hook, mutable=False):
                 value = new_value # compare new value with cached
             else:
                 value = fget(self) # compare current value with cached
-            if _cmp_cached_mutable_property(self, "change hook property", name, value) != 0:
+            if _cmp_cached_mutable_property(self, "change hook property", name, value, default) != 0:
                 # there has been a change, cache new value
-                old_value = _get_cached_mutable_property(self, "change hook property", name)
+                old_value = _get_cached_mutable_property(self, "change hook property", name, default)
                 _set_cached_mutable_property(self, "change hook property", name, value)
                 if from_fset == True: # return previously cached value
                     value = old_value
index 294b8e063e9802cbfeeb1935f445134678ba6f8a..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 (file)
@@ -1,876 +0,0 @@
-# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
-#                         Alexander Belchenko <bialix@ukr.net>
-#                         Ben Finney <ben+python@benfinney.id.au>
-#                         Chris Ball <cjb@laptop.org>
-#                         W. Trevor King <wking@drexel.edu>
-#
-# 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.
-
-from subprocess import Popen, PIPE
-import codecs
-import os
-import os.path
-import re
-from socket import gethostname
-import shutil
-import sys
-import tempfile
-import unittest
-import doctest
-
-from utility import Dir, search_parent_directories
-
-
-def _get_matching_rcs(matchfn):
-    """Return the first module for which matchfn(RCS_instance) is true"""
-    import arch
-    import bzr
-    import darcs
-    import git
-    import hg
-    for module in [arch, bzr, darcs, git, hg]:
-        rcs = module.new()
-        if matchfn(rcs) == True:
-            return rcs
-        del(rcs)
-    return RCS()
-    
-def rcs_by_name(rcs_name):
-    """Return the module for the RCS with the given name"""
-    return _get_matching_rcs(lambda rcs: rcs.name == rcs_name)
-
-def detect_rcs(dir):
-    """Return an RCS instance for the rcs being used in this directory"""
-    return _get_matching_rcs(lambda rcs: rcs.detect(dir))
-
-def installed_rcs():
-    """Return an instance of an installed RCS"""
-    return _get_matching_rcs(lambda rcs: rcs.installed())
-
-
-class CommandError(Exception):
-    def __init__(self, command, status, err_str):
-        strerror = ["Command failed (%d):\n  %s\n" % (status, err_str),
-                    "while executing\n  %s" % command]
-        Exception.__init__(self, "\n".join(strerror))
-        self.command = command
-        self.status = status
-        self.err_str = err_str
-
-class SettingIDnotSupported(NotImplementedError):
-    pass
-
-class RCSnotRooted(Exception):
-    def __init__(self):
-        msg = "RCS not rooted"
-        Exception.__init__(self, msg)
-
-class PathNotInRoot(Exception):
-    def __init__(self, path, root):
-        msg = "Path '%s' not in root '%s'" % (path, root)
-        Exception.__init__(self, msg)
-        self.path = path
-        self.root = root
-
-class NoSuchFile(Exception):
-    def __init__(self, pathname, root="."):
-        path = os.path.abspath(os.path.join(root, pathname))
-        Exception.__init__(self, "No such file: %s" % path)
-
-class EmptyCommit(Exception):
-    def __init__(self):
-        Exception.__init__(self, "No changes to commit")
-
-
-def new():
-    return RCS()
-
-class RCS(object):
-    """
-    This class implements a 'no-rcs' interface.
-
-    Support for other RCSs can be added by subclassing this class, and
-    overriding methods _rcs_*() with code appropriate for your RCS.
-    
-    The methods _u_*() are utility methods available to the _rcs_*()
-    methods.
-    """
-    name = "None"
-    client = "" # command-line tool for _u_invoke_client
-    versioned = False
-    def __init__(self, paranoid=False, encoding=sys.getdefaultencoding()):
-        self.paranoid = paranoid
-        self.verboseInvoke = False
-        self.rootdir = None
-        self._duplicateBasedir = None
-        self._duplicateDirname = None
-        self.encoding = encoding
-    def __del__(self):
-        self.cleanup()
-
-    def _rcs_help(self):
-        """
-        Return the command help string.
-        (Allows a simple test to see if the client is installed.)
-        """
-        pass
-    def _rcs_detect(self, path=None):
-        """
-        Detect whether a directory is revision controlled with this RCS.
-        """
-        return True
-    def _rcs_root(self, path):
-        """
-        Get the RCS root.  This is the default working directory for
-        future invocations.  You would normally set this to the root
-        directory for your RCS.
-        """
-        if os.path.isdir(path)==False:
-            path = os.path.dirname(path)
-            if path == "":
-                path = os.path.abspath(".")
-        return path
-    def _rcs_init(self, path):
-        """
-        Begin versioning the tree based at path.
-        """
-        pass
-    def _rcs_cleanup(self):
-        """
-        Remove any cruft that _rcs_init() created outside of the
-        versioned tree.
-        """
-        pass
-    def _rcs_get_user_id(self):
-        """
-        Get the RCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
-        If the RCS has not been configured with a username, return None.
-        """
-        return None
-    def _rcs_set_user_id(self, value):
-        """
-        Set the RCS's suggested user id (e.g "John Doe <jdoe@example.com>").
-        This is run if the RCS has not been configured with a usename, so
-        that commits will have a reasonable FROM value.
-        """
-        raise SettingIDnotSupported
-    def _rcs_add(self, path):
-        """
-        Add the already created file at path to version control.
-        """
-        pass
-    def _rcs_remove(self, path):
-        """
-        Remove the file at path from version control.  Optionally
-        remove the file from the filesystem as well.
-        """
-        pass
-    def _rcs_update(self, path):
-        """
-        Notify the versioning system of changes to the versioned file
-        at path.
-        """
-        pass
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
-        """
-        Get the file contents as they were in a given revision.
-        Revision==None specifies the current revision.
-        """
-        assert revision == None, \
-            "The %s RCS does not support revision specifiers" % self.name
-        if binary == False:
-            f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding)
-        else:
-            f = open(os.path.join(self.rootdir, path), "rb")
-        contents = f.read()
-        f.close()
-        return contents
-    def _rcs_duplicate_repo(self, directory, revision=None):
-        """
-        Get the repository as it was in a given revision.
-        revision==None specifies the current revision.
-        dir specifies a directory to create the duplicate in.
-        """
-        shutil.copytree(self.rootdir, directory, True)
-    def _rcs_commit(self, commitfile, allow_empty=False):
-        """
-        Commit the current working directory, using the contents of
-        commitfile as the comment.  Return the name of the old
-        revision (or None if commits are not supported).
-        
-        If allow_empty == False, raise EmptyCommit if there are no
-        changes to commit.
-        """
-        return None
-    def installed(self):
-        try:
-            self._rcs_help()
-            return True
-        except OSError, e:
-            if e.errno == errno.ENOENT:
-                return False
-        except CommandError:
-            return False
-    def detect(self, path="."):
-        """
-        Detect whether a directory is revision controlled with this RCS.
-        """
-        return self._rcs_detect(path)
-    def root(self, path):
-        """
-        Set the root directory to the path's RCS root.  This is the
-        default working directory for future invocations.
-        """
-        self.rootdir = self._rcs_root(path)
-    def init(self, path):
-        """
-        Begin versioning the tree based at path.
-        Also roots the rcs at path.
-        """
-        if os.path.isdir(path)==False:
-            path = os.path.dirname(path)
-        self._rcs_init(path)
-        self.root(path)
-    def cleanup(self):
-        self._rcs_cleanup()
-    def get_user_id(self):
-        """
-        Get the RCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
-        If the RCS has not been configured with a username, return the user's
-        id.  You can override the automatic lookup procedure by setting the
-        RCS.user_id attribute to a string of your choice.
-        """
-        if hasattr(self, "user_id"):
-            if self.user_id != None:
-                return self.user_id
-        id = self._rcs_get_user_id()
-        if id == None:
-            name = self._u_get_fallback_username()
-            email = self._u_get_fallback_email()
-            id = self._u_create_id(name, email)
-            print >> sys.stderr, "Guessing id '%s'" % id
-            try:
-                self.set_user_id(id)
-            except SettingIDnotSupported:
-                pass
-        return id
-    def set_user_id(self, value):
-        """
-        Set the RCS's suggested user id (e.g "John Doe <jdoe@example.com>").
-        This is run if the RCS has not been configured with a usename, so
-        that commits will have a reasonable FROM value.
-        """
-        self._rcs_set_user_id(value)
-    def add(self, path):
-        """
-        Add the already created file at path to version control.
-        """
-        self._rcs_add(self._u_rel_path(path))
-    def remove(self, path):
-        """
-        Remove a file from both version control and the filesystem.
-        """
-        self._rcs_remove(self._u_rel_path(path))
-        if os.path.exists(path):
-            os.remove(path)
-    def recursive_remove(self, dirname):
-        """
-        Remove a file/directory and all its decendents from both
-        version control and the filesystem.
-        """
-        if not os.path.exists(dirname):
-            raise NoSuchFile(dirname)
-        for dirpath,dirnames,filenames in os.walk(dirname, topdown=False):
-            filenames.extend(dirnames)
-            for path in filenames:
-                fullpath = os.path.join(dirpath, path)
-                if os.path.exists(fullpath) == False:
-                    continue
-                self._rcs_remove(self._u_rel_path(fullpath))
-        if os.path.exists(dirname):
-            shutil.rmtree(dirname)
-    def update(self, path):
-        """
-        Notify the versioning system of changes to the versioned file
-        at path.
-        """
-        self._rcs_update(self._u_rel_path(path))
-    def get_file_contents(self, path, revision=None, allow_no_rcs=False, binary=False):
-        """
-        Get the file as it was in a given revision.
-        Revision==None specifies the current revision.
-        """
-        if not os.path.exists(path):
-            raise NoSuchFile(path)
-        if self._use_rcs(path, allow_no_rcs):
-            relpath = self._u_rel_path(path)
-            contents = self._rcs_get_file_contents(relpath,revision,binary=binary)
-        else:
-            f = codecs.open(path, "r", self.encoding)
-            contents = f.read()
-            f.close()
-        return contents
-    def set_file_contents(self, path, contents, allow_no_rcs=False, binary=False):
-        """
-        Set the file contents under version control.
-        """
-        add = not os.path.exists(path)
-        if binary == False:
-            f = codecs.open(path, "w", self.encoding)
-        else:
-            f = open(path, "wb")
-        f.write(contents)
-        f.close()
-        
-        if self._use_rcs(path, allow_no_rcs):
-            if add:
-                self.add(path)
-            else:
-                self.update(path)
-    def mkdir(self, path, allow_no_rcs=False, check_parents=True):
-        """
-        Create (if neccessary) a directory at path under version
-        control.
-        """
-        if check_parents == True:
-            parent = os.path.dirname(path)
-            if not os.path.exists(parent): # recurse through parents
-                self.mkdir(parent, allow_no_rcs, check_parents)
-        if not os.path.exists(path):
-            os.mkdir(path)
-            if self._use_rcs(path, allow_no_rcs):
-                self.add(path)
-        else:
-            assert os.path.isdir(path)
-            if self._use_rcs(path, allow_no_rcs):
-                #self.update(path)# Don't update directories.  Changing files
-                pass              # underneath them should be sufficient.
-                
-    def duplicate_repo(self, revision=None):
-        """
-        Get the repository as it was in a given revision.
-        revision==None specifies the current revision.
-        Return the path to the arbitrary directory at the base of the new repo.
-        """
-        # Dirname in Baseir to protect against simlink attacks.
-        if self._duplicateBasedir == None:
-            self._duplicateBasedir = tempfile.mkdtemp(prefix='BErcs')
-            self._duplicateDirname = \
-                os.path.join(self._duplicateBasedir, "duplicate")
-            self._rcs_duplicate_repo(directory=self._duplicateDirname,
-                                     revision=revision)
-        return self._duplicateDirname
-    def remove_duplicate_repo(self):
-        """
-        Clean up a duplicate repo created with duplicate_repo().
-        """
-        if self._duplicateBasedir != None:
-            shutil.rmtree(self._duplicateBasedir)
-            self._duplicateBasedir = None
-            self._duplicateDirname = None
-    def commit(self, summary, body=None, allow_empty=False):
-        """
-        Commit the current working directory, with a commit message
-        string summary and body.  Return the name of the old revision
-        (or None if versioning is not supported).
-        
-        If allow_empty == False (the default), raise EmptyCommit if
-        there are no changes to commit.
-        """
-        summary = summary.strip()+'\n'
-        if body is not None:
-            summary += '\n' + body.strip() + '\n'
-        descriptor, filename = tempfile.mkstemp()
-        revision = None
-        try:
-            temp_file = os.fdopen(descriptor, 'wb')
-            temp_file.write(summary)
-            temp_file.flush()
-            self.precommit()
-            revision = self._rcs_commit(filename, allow_empty=allow_empty)
-            temp_file.close()
-            self.postcommit()
-        finally:
-            os.remove(filename)
-        return revision
-    def precommit(self):
-        """
-        Executed before all attempted commits.
-        """
-        pass
-    def postcommit(self):
-        """
-        Only executed after successful commits.
-        """
-        pass
-    def _u_any_in_string(self, list, string):
-        """
-        Return True if any of the strings in list are in string.
-        Otherwise return False.
-        """
-        for list_string in list:
-            if list_string in string:
-                return True
-        return False
-    def _u_invoke(self, args, stdin=None, expect=(0,), cwd=None):
-        """
-        expect should be a tuple of allowed exit codes.  cwd should be
-        the directory from which the command will be executed.
-        """
-        if cwd == None:
-            cwd = self.rootdir
-        if self.verboseInvoke == True:
-            print >> sys.stderr, "%s$ %s" % (cwd, " ".join(args))
-        try :
-            if sys.platform != "win32":
-                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd)
-            else:
-                # win32 don't have os.execvp() so have to run command in a shell
-                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, 
-                          shell=True, cwd=cwd)
-        except OSError, e :
-            raise CommandError(args, e.args[0], e)
-        output, error = q.communicate(input=stdin)
-        status = q.wait()
-        if self.verboseInvoke == True:
-            print >> sys.stderr, "%d\n%s%s" % (status, output, error)
-        if status not in expect:
-            raise CommandError(args, status, error)
-        return status, output, error
-    def _u_invoke_client(self, *args, **kwargs):
-        directory = kwargs.get('directory',None)
-        expect = kwargs.get('expect', (0,))
-        stdin = kwargs.get('stdin', None)
-        cl_args = [self.client]
-        cl_args.extend(args)
-        return self._u_invoke(cl_args, stdin=stdin,expect=expect,cwd=directory)
-    def _u_search_parent_directories(self, path, filename):
-        """
-        Find the file (or directory) named filename in path or in any
-        of path's parents.
-        
-        e.g.
-          search_parent_directories("/a/b/c", ".be")
-        will return the path to the first existing file from
-          /a/b/c/.be
-          /a/b/.be
-          /a/.be
-          /.be
-        or None if none of those files exist.
-        """
-        return search_parent_directories(path, filename)
-    def _use_rcs(self, path, allow_no_rcs):
-        """
-        Try and decide if _rcs_add/update/mkdir/etc calls will
-        succeed.  Returns True is we think the rcs_call would
-        succeeed, and False otherwise.
-        """
-        use_rcs = True
-        exception = None
-        if self.rootdir != None:
-            if self.path_in_root(path) == False:
-                use_rcs = False
-                exception = PathNotInRoot(path, self.rootdir)
-        else:
-            use_rcs = False
-            exception = RCSnotRooted
-        if use_rcs == False and allow_no_rcs==False:
-            raise exception
-        return use_rcs
-    def path_in_root(self, path, root=None):
-        """
-        Return the relative path to path from root.
-        >>> rcs = new()
-        >>> rcs.path_in_root("/a.b/c/.be", "/a.b/c")
-        True
-        >>> rcs.path_in_root("/a.b/.be", "/a.b/c")
-        False
-        """
-        if root == None:
-            if self.rootdir == None:
-                raise RCSnotRooted
-            root = self.rootdir
-        path = os.path.abspath(path)
-        absRoot = os.path.abspath(root)
-        absRootSlashedDir = os.path.join(absRoot,"")
-        if not path.startswith(absRootSlashedDir):
-            return False
-        return True
-    def _u_rel_path(self, path, root=None):
-        """
-        Return the relative path to path from root.
-        >>> rcs = new()
-        >>> rcs._u_rel_path("/a.b/c/.be", "/a.b/c")
-        '.be'
-        """
-        if root == None:
-            if self.rootdir == None:
-                raise RCSnotRooted
-            root = self.rootdir
-        path = os.path.abspath(path)
-        absRoot = os.path.abspath(root)
-        absRootSlashedDir = os.path.join(absRoot,"")
-        if not path.startswith(absRootSlashedDir):
-            raise PathNotInRoot(path, absRootSlashedDir)
-        assert path != absRootSlashedDir, \
-            "file %s == root directory %s" % (path, absRootSlashedDir)
-        relpath = path[len(absRootSlashedDir):]
-        return relpath
-    def _u_abspath(self, path, root=None):
-        """
-        Return the absolute path from a path realtive to root.
-        >>> rcs = new()
-        >>> rcs._u_abspath(".be", "/a.b/c")
-        '/a.b/c/.be'
-        """
-        if root == None:
-            assert self.rootdir != None, "RCS not rooted"
-            root = self.rootdir
-        return os.path.abspath(os.path.join(root, path))
-    def _u_create_id(self, name, email=None):
-        """
-        >>> rcs = new()
-        >>> rcs._u_create_id("John Doe", "jdoe@example.com")
-        'John Doe <jdoe@example.com>'
-        >>> rcs._u_create_id("John Doe")
-        'John Doe'
-        """
-        assert len(name) > 0
-        if email == None or len(email) == 0:
-            return name
-        else:
-            return "%s <%s>" % (name, email)
-    def _u_parse_id(self, value):
-        """
-        >>> rcs = new()
-        >>> rcs._u_parse_id("John Doe <jdoe@example.com>")
-        ('John Doe', 'jdoe@example.com')
-        >>> rcs._u_parse_id("John Doe")
-        ('John Doe', None)
-        >>> try:
-        ...     rcs._u_parse_id("John Doe <jdoe@example.com><what?>")
-        ... except AssertionError:
-        ...     print "Invalid match"
-        Invalid match
-        """
-        emailexp = re.compile("(.*) <([^>]*)>(.*)")
-        match = emailexp.search(value)
-        if match == None:
-            email = None
-            name = value
-        else:
-            assert len(match.groups()) == 3
-            assert match.groups()[2] == "", match.groups()
-            email = match.groups()[1]
-            name = match.groups()[0]
-        assert name != None
-        assert len(name) > 0
-        return (name, email)
-    def _u_get_fallback_username(self):
-        name = None
-        for envariable in ["LOGNAME", "USERNAME"]:
-            if os.environ.has_key(envariable):
-                name = os.environ[envariable]
-                break
-        assert name != None
-        return name
-    def _u_get_fallback_email(self):
-        hostname = gethostname()
-        name = self._u_get_fallback_username()
-        return "%s@%s" % (name, hostname)
-    def _u_parse_commitfile(self, commitfile):
-        """
-        Split the commitfile created in self.commit() back into
-        summary and header lines.
-        """
-        f = codecs.open(commitfile, "r", self.encoding)
-        summary = f.readline()
-        body = f.read()
-        body.lstrip('\n')
-        if len(body) == 0:
-            body = None
-        f.close()
-        return (summary, body)
-        
-\f
-def setup_rcs_test_fixtures(testcase):
-    """Set up test fixtures for RCS test case."""
-    testcase.rcs = testcase.Class()
-    testcase.dir = Dir()
-    testcase.dirname = testcase.dir.path
-
-    rcs_not_supporting_uninitialized_user_id = []
-    rcs_not_supporting_set_user_id = ["None", "hg"]
-    testcase.rcs_supports_uninitialized_user_id = (
-        testcase.rcs.name not in rcs_not_supporting_uninitialized_user_id)
-    testcase.rcs_supports_set_user_id = (
-        testcase.rcs.name not in rcs_not_supporting_set_user_id)
-
-    if not testcase.rcs.installed():
-        testcase.fail(
-            "%(name)s RCS not found" % vars(testcase.Class))
-
-    if testcase.Class.name != "None":
-        testcase.failIf(
-            testcase.rcs.detect(testcase.dirname),
-            "Detected %(name)s RCS before initialising"
-                % vars(testcase.Class))
-
-    testcase.rcs.init(testcase.dirname)
-
-
-class RCSTestCase(unittest.TestCase):
-    """Test cases for base RCS class."""
-
-    Class = RCS
-
-    def __init__(self, *args, **kwargs):
-        super(RCSTestCase, self).__init__(*args, **kwargs)
-        self.dirname = None
-
-    def setUp(self):
-        super(RCSTestCase, self).setUp()
-        setup_rcs_test_fixtures(self)
-
-    def tearDown(self):
-        del(self.rcs)
-        super(RCSTestCase, self).tearDown()
-
-    def full_path(self, rel_path):
-        return os.path.join(self.dirname, rel_path)
-
-
-class RCS_init_TestCase(RCSTestCase):
-    """Test cases for RCS.init method."""
-
-    def test_detect_should_succeed_after_init(self):
-        """Should detect RCS in directory after initialization."""
-        self.failUnless(
-            self.rcs.detect(self.dirname),
-            "Did not detect %(name)s RCS after initialising"
-                % vars(self.Class))
-
-    def test_rcs_rootdir_in_specified_root_path(self):
-        """RCS root directory should be in specified root path."""
-        rp = os.path.realpath(self.rcs.rootdir)
-        dp = os.path.realpath(self.dirname)
-        rcs_name = self.Class.name
-        self.failUnless(
-            dp == rp or rp == None,
-            "%(rcs_name)s RCS root in wrong dir (%(dp)s %(rp)s)" % vars())
-
-
-class RCS_get_user_id_TestCase(RCSTestCase):
-    """Test cases for RCS.get_user_id method."""
-
-    def test_gets_existing_user_id(self):
-        """Should get the existing user ID."""
-        if not self.rcs_supports_uninitialized_user_id:
-            return
-
-        user_id = self.rcs.get_user_id()
-        self.failUnless(
-            user_id is not None,
-            "unable to get a user id")
-
-
-class RCS_set_user_id_TestCase(RCSTestCase):
-    """Test cases for RCS.set_user_id method."""
-
-    def setUp(self):
-        super(RCS_set_user_id_TestCase, self).setUp()
-
-        if self.rcs_supports_uninitialized_user_id:
-            self.prev_user_id = self.rcs.get_user_id()
-        else:
-            self.prev_user_id = "Uninitialized identity <bogus@example.org>"
-
-        if self.rcs_supports_set_user_id:
-            self.test_new_user_id = "John Doe <jdoe@example.com>"
-            self.rcs.set_user_id(self.test_new_user_id)
-
-    def tearDown(self):
-        if self.rcs_supports_set_user_id:
-            self.rcs.set_user_id(self.prev_user_id)
-        super(RCS_set_user_id_TestCase, self).tearDown()
-
-    def test_raises_error_in_unsupported_vcs(self):
-        """Should raise an error in a VCS that doesn't support it."""
-        if self.rcs_supports_set_user_id:
-            return
-        self.assertRaises(
-            SettingIDnotSupported,
-            self.rcs.set_user_id, "foo")
-
-    def test_updates_user_id_in_supporting_rcs(self):
-        """Should update the user ID in an RCS that supports it."""
-        if not self.rcs_supports_set_user_id:
-            return
-        user_id = self.rcs.get_user_id()
-        self.failUnlessEqual(
-            self.test_new_user_id, user_id,
-            "user id not set correctly (expected %s, got %s)"
-                % (self.test_new_user_id, user_id))
-
-
-def setup_rcs_revision_test_fixtures(testcase):
-    """Set up revision test fixtures for RCS test case."""
-    testcase.test_dirs = ['a', 'a/b', 'c']
-    for path in testcase.test_dirs:
-        testcase.rcs.mkdir(testcase.full_path(path))
-
-    testcase.test_files = ['a/text', 'a/b/text']
-
-    testcase.test_contents = {
-        'rev_1': "Lorem ipsum",
-        'uncommitted': "dolor sit amet",
-        }
-
-
-class RCS_mkdir_TestCase(RCSTestCase):
-    """Test cases for RCS.mkdir method."""
-
-    def setUp(self):
-        super(RCS_mkdir_TestCase, self).setUp()
-        setup_rcs_revision_test_fixtures(self)
-
-    def tearDown(self):
-        for path in reversed(sorted(self.test_dirs)):
-            self.rcs.recursive_remove(self.full_path(path))
-        super(RCS_mkdir_TestCase, self).tearDown()
-
-    def test_mkdir_creates_directory(self):
-        """Should create specified directory in filesystem."""
-        for path in self.test_dirs:
-            full_path = self.full_path(path)
-            self.failUnless(
-                os.path.exists(full_path),
-                "path %(full_path)s does not exist" % vars())
-
-
-class RCS_commit_TestCase(RCSTestCase):
-    """Test cases for RCS.commit method."""
-
-    def setUp(self):
-        super(RCS_commit_TestCase, self).setUp()
-        setup_rcs_revision_test_fixtures(self)
-
-    def tearDown(self):
-        for path in reversed(sorted(self.test_dirs)):
-            self.rcs.recursive_remove(self.full_path(path))
-        super(RCS_commit_TestCase, self).tearDown()
-
-    def test_file_contents_as_specified(self):
-        """Should set file contents as specified."""
-        test_contents = self.test_contents['rev_1']
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(full_path, test_contents)
-            current_contents = self.rcs.get_file_contents(full_path)
-            self.failUnlessEqual(test_contents, current_contents)
-
-    def test_file_contents_as_committed(self):
-        """Should have file contents as specified after commit."""
-        test_contents = self.test_contents['rev_1']
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(full_path, test_contents)
-            revision = self.rcs.commit("Initial file contents.")
-            current_contents = self.rcs.get_file_contents(full_path)
-            self.failUnlessEqual(test_contents, current_contents)
-
-    def test_file_contents_as_set_when_uncommitted(self):
-        """Should set file contents as specified after commit."""
-        if not self.rcs.versioned:
-            return
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['rev_1'])
-            revision = self.rcs.commit("Initial file contents.")
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['uncommitted'])
-            current_contents = self.rcs.get_file_contents(full_path)
-            self.failUnlessEqual(
-                self.test_contents['uncommitted'], current_contents)
-
-    def test_revision_file_contents_as_committed(self):
-        """Should get file contents as committed to specified revision."""
-        if not self.rcs.versioned:
-            return
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['rev_1'])
-            revision = self.rcs.commit("Initial file contents.")
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['uncommitted'])
-            committed_contents = self.rcs.get_file_contents(
-                full_path, revision)
-            self.failUnlessEqual(
-                self.test_contents['rev_1'], committed_contents)
-
-
-class RCS_duplicate_repo_TestCase(RCSTestCase):
-    """Test cases for RCS.duplicate_repo method."""
-
-    def setUp(self):
-        super(RCS_duplicate_repo_TestCase, self).setUp()
-        setup_rcs_revision_test_fixtures(self)
-
-    def tearDown(self):
-        self.rcs.remove_duplicate_repo()
-        for path in reversed(sorted(self.test_dirs)):
-            self.rcs.recursive_remove(self.full_path(path))
-        super(RCS_duplicate_repo_TestCase, self).tearDown()
-
-    def test_revision_file_contents_as_committed(self):
-        """Should match file contents as committed to specified revision."""
-        if not self.rcs.versioned:
-            return
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['rev_1'])
-            revision = self.rcs.commit("Commit current status")
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['uncommitted'])
-            dup_repo_path = self.rcs.duplicate_repo(revision)
-            dup_file_path = os.path.join(dup_repo_path, path)
-            dup_file_contents = file(dup_file_path, 'rb').read()
-            self.failUnlessEqual(
-                self.test_contents['rev_1'], dup_file_contents)
-            self.rcs.remove_duplicate_repo()
-
-
-def make_rcs_testcase_subclasses(rcs_class, namespace):
-    """Make RCSTestCase subclasses for rcs_class in the namespace."""
-    rcs_testcase_classes = [
-        c for c in (
-            ob for ob in globals().values() if isinstance(ob, type))
-        if issubclass(c, RCSTestCase)]
-
-    for base_class in rcs_testcase_classes:
-        testcase_class_name = rcs_class.__name__ + base_class.__name__
-        testcase_class_bases = (base_class,)
-        testcase_class_dict = dict(base_class.__dict__)
-        testcase_class_dict['Class'] = rcs_class
-        testcase_class = type(
-            testcase_class_name, testcase_class_bases, testcase_class_dict)
-        setattr(namespace, testcase_class_name, testcase_class)
-
-
-unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
-suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index dde247fb65c399eac52c455fa82cfa0f1410ee2e..ceea9d551e831b0b4d8b19719bcd7fad3ce75c42 100644 (file)
@@ -148,7 +148,8 @@ def versioned_property(name, doc,
             checked = checked_property(allowed=allowed)
             fulldoc += "\n\nThe allowed values for this property are: %s." \
                        % (', '.join(allowed))
-        hooked      = change_hook_property(hook=change_hook, mutable=mutable)
+        hooked      = change_hook_property(hook=change_hook, mutable=mutable,
+                                           default=EMPTY)
         primed      = primed_property(primer=primer, initVal=UNPRIMED)
         settings    = settings_property(name=name, null=UNPRIMED)
         docp        = doc_property(doc=fulldoc)
@@ -385,30 +386,24 @@ class SavedSettingsObjectTests(unittest.TestCase):
         self.failUnless(SAVES == [], SAVES)
         self.failUnless(t._settings_loaded == True, t._settings_loaded)
         self.failUnless(t.list_type == None, t.list_type)
-        self.failUnless(SAVES == [
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'"
-                ], SAVES)
+        self.failUnless(SAVES == [], SAVES)
         self.failUnless(t.settings["List-type"]==EMPTY,t.settings["List-type"])
         t.list_type = []
         self.failUnless(t.settings["List-type"] == [], t.settings["List-type"])
         self.failUnless(SAVES == [
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'"
                 ], SAVES)
         t.list_type.append(5)
         self.failUnless(SAVES == [
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
                 ], SAVES)
         self.failUnless(t.settings["List-type"] == [5],t.settings["List-type"])
         self.failUnless(SAVES == [ # the append(5) has not yet been saved
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
                 ], SAVES)
         self.failUnless(t.list_type == [5], t.list_type) # <-get triggers saved
 
         self.failUnless(SAVES == [ # now the append(5) has been saved.
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
                 "'[]' -> '[5]'"
                 ], SAVES)
index 45ae0855b47cd7a4ff50034f8a4ab79a156ad24a..06d09e55f7a6073ae84292c7c141ca1553941fcc 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Define a traversable tree structure.
+"""
+
 import doctest
 
 class Tree(list):
diff --git a/interfaces/web/Bugs-Everywhere-Web/libbe/upgrade.py b/interfaces/web/Bugs-Everywhere-Web/libbe/upgrade.py
new file mode 100644 (file)
index 0000000..4123c72
--- /dev/null
@@ -0,0 +1,187 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Handle conversion between the various on-disk images.
+"""
+
+import os, os.path
+import sys
+import doctest
+
+import encoding
+import mapfile
+import vcs
+
+# a list of all past versions
+BUGDIR_DISK_VERSIONS = ["Bugs Everywhere Tree 1 0",
+                        "Bugs Everywhere Directory v1.1",
+                        "Bugs Everywhere Directory v1.2"]
+
+# the current version
+BUGDIR_DISK_VERSION = BUGDIR_DISK_VERSIONS[-1]
+
+class Upgrader (object):
+    "Class for converting "
+    initial_version = None
+    final_version = None
+    def __init__(self, root):
+        self.root = root
+        # use the "None" VCS to ensure proper encoding/decoding and
+        # simplify path construction.
+        self.vcs = vcs.vcs_by_name("None")
+        self.vcs.root(self.root)
+        self.vcs.encoding = encoding.get_encoding()
+
+    def get_path(self, *args):
+        """
+        Return a path relative to .root.
+        """
+        dir = os.path.join(self.root, ".be")
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["version", "settings", "bugs"], str(args)
+        return os.path.join(dir, *args)
+
+    def check_initial_version(self):
+        path = self.get_path("version")
+        version = self.vcs.get_file_contents(path).rstrip("\n")
+        assert version == self.initial_version, version
+
+    def set_version(self):
+        path = self.get_path("version")
+        self.vcs.set_file_contents(path, self.final_version+"\n")
+
+    def upgrade(self):
+        print >> sys.stderr, "upgrading bugdir from '%s' to '%s'" \
+            % (self.initial_version, self.final_version)
+        self.check_initial_version()
+        self.set_version()
+        self._upgrade()
+
+    def _upgrade(self):
+        raise NotImplementedError
+
+
+class Upgrade_1_0_to_1_1 (Upgrader):
+    initial_version = "Bugs Everywhere Tree 1 0"
+    final_version = "Bugs Everywhere Directory v1.1"
+    def _upgrade_mapfile(self, path):
+        contents = self.vcs.get_file_contents(path)
+        old_format = False
+        for line in contents.splitlines():
+            if len(line.split("=")) == 2:
+                old_format = True
+                break
+        if old_format == True:
+            # translate to YAML.
+            newlines = []
+            for line in contents.splitlines():
+                line = line.rstrip('\n')
+                if len(line) == 0:
+                    continue
+                fields = line.split("=")
+                if len(fields) == 2:
+                    key,value = fields
+                    newlines.append('%s: "%s"' % (key, value.replace('"','\\"')))
+                else:
+                    newlines.append(line)
+            contents = '\n'.join(newlines)
+            # load the YAML and save
+            map = mapfile.parse(contents)
+            mapfile.map_save(self.vcs, path, map)
+
+    def _upgrade(self):
+        """
+        Comment value field "From" -> "Author".
+        Homegrown mapfile -> YAML.
+        """
+        path = self.get_path("settings")
+        self._upgrade_mapfile(path)
+        for bug_uuid in os.listdir(self.get_path("bugs")):
+            path = self.get_path("bugs", bug_uuid, "values")
+            self._upgrade_mapfile(path)
+            c_path = ["bugs", bug_uuid, "comments"]
+            if not os.path.exists(self.get_path(*c_path)):
+                continue # no comments for this bug
+            for comment_uuid in os.listdir(self.get_path(*c_path)):
+                path_list = c_path + [comment_uuid, "values"]
+                path = self.get_path(*path_list)
+                self._upgrade_mapfile(path)
+                settings = mapfile.map_load(self.vcs, path)
+                if "From" in settings:
+                    settings["Author"] = settings.pop("From")
+                    mapfile.map_save(self.vcs, path, settings)
+
+
+class Upgrade_1_1_to_1_2 (Upgrader):
+    initial_version = "Bugs Everywhere Directory v1.1"
+    final_version = "Bugs Everywhere Directory v1.2"
+    def _upgrade(self):
+        """
+        BugDir settings field "rcs_name" -> "vcs_name".
+        """
+        path = self.get_path("settings")
+        settings = mapfile.map_load(self.vcs, path)
+        if "rcs_name" in settings:
+            settings["vcs_name"] = settings.pop("rcs_name")
+            mapfile.map_save(self.vcs, path, settings)
+
+
+upgraders = [Upgrade_1_0_to_1_1,
+             Upgrade_1_1_to_1_2]
+upgrade_classes = {}
+for upgrader in upgraders:
+    upgrade_classes[(upgrader.initial_version,upgrader.final_version)]=upgrader
+
+def upgrade(path, current_version,
+            target_version=BUGDIR_DISK_VERSION):
+    """
+    Call the appropriate upgrade function to convert current_version
+    to target_version.  If a direct conversion function does not exist,
+    use consecutive conversion functions.
+    """
+    if current_version not in BUGDIR_DISK_VERSIONS:
+        raise NotImplementedError, \
+            "Cannot handle version '%s' yet." % version
+    if target_version not in BUGDIR_DISK_VERSIONS:
+        raise NotImplementedError, \
+            "Cannot handle version '%s' yet." % version
+
+    if (current_version, target_version) in upgrade_classes:
+        # direct conversion
+        upgrade_class = upgrade_classes[(current_version, target_version)]
+        u = upgrade_class(path)
+        u.upgrade()
+    else:
+        # consecutive single-step conversion
+        i = BUGDIR_DISK_VERSIONS.index(current_version)
+        while True:
+            version_a = BUGDIR_DISK_VERSIONS[i]
+            version_b = BUGDIR_DISK_VERSIONS[i+1]
+            try:
+                upgrade_class = upgrade_classes[(version_a, version_b)]
+            except KeyError:
+                raise NotImplementedError, \
+                    "Cannot convert version '%s' to '%s' yet." \
+                    % (version_a, version_b)
+            u = upgrade_class(path)
+            u.upgrade()
+            if version_b == target_version:
+                break
+            i += 1
+
+suite = doctest.DocTestSuite()
index 3df06b40b7373c26e62bef2bce932f41c3631723..aafbf8d164c5d77b446114d28347299c450c9b38 100644 (file)
 # 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.
+
+"""
+Assorted utility functions that don't fit in anywhere else.
+"""
+
 import calendar
 import codecs
 import os
diff --git a/interfaces/web/Bugs-Everywhere-Web/libbe/vcs.py b/interfaces/web/Bugs-Everywhere-Web/libbe/vcs.py
new file mode 100644 (file)
index 0000000..a1d3022
--- /dev/null
@@ -0,0 +1,938 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Alexander Belchenko <bialix@ukr.net>
+#                         Ben Finney <ben+python@benfinney.id.au>
+#                         Chris Ball <cjb@laptop.org>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define the base VCS (Version Control System) class, which should be
+subclassed by other Version Control System backends.  The base class
+implements a "do not version" VCS.
+"""
+
+from subprocess import Popen, PIPE
+import codecs
+import os
+import os.path
+import re
+from socket import gethostname
+import shutil
+import sys
+import tempfile
+import unittest
+import doctest
+
+from utility import Dir, search_parent_directories
+
+
+def _get_matching_vcs(matchfn):
+    """Return the first module for which matchfn(VCS_instance) is true"""
+    import arch
+    import bzr
+    import darcs
+    import git
+    import hg
+    for module in [arch, bzr, darcs, git, hg]:
+        vcs = module.new()
+        if matchfn(vcs) == True:
+            return vcs
+        del(vcs)
+    return VCS()
+    
+def vcs_by_name(vcs_name):
+    """Return the module for the VCS with the given name"""
+    return _get_matching_vcs(lambda vcs: vcs.name == vcs_name)
+
+def detect_vcs(dir):
+    """Return an VCS instance for the vcs being used in this directory"""
+    return _get_matching_vcs(lambda vcs: vcs.detect(dir))
+
+def installed_vcs():
+    """Return an instance of an installed VCS"""
+    return _get_matching_vcs(lambda vcs: vcs.installed())
+
+
+class CommandError(Exception):
+    def __init__(self, command, status, stdout, stderr):
+        strerror = ["Command failed (%d):\n  %s\n" % (status, stderr),
+                    "while executing\n  %s" % command]
+        Exception.__init__(self, "\n".join(strerror))
+        self.command = command
+        self.status = status
+        self.stdout = stdout
+        self.stderr = stderr
+
+class SettingIDnotSupported(NotImplementedError):
+    pass
+
+class VCSnotRooted(Exception):
+    def __init__(self):
+        msg = "VCS not rooted"
+        Exception.__init__(self, msg)
+
+class PathNotInRoot(Exception):
+    def __init__(self, path, root):
+        msg = "Path '%s' not in root '%s'" % (path, root)
+        Exception.__init__(self, msg)
+        self.path = path
+        self.root = root
+
+class NoSuchFile(Exception):
+    def __init__(self, pathname, root="."):
+        path = os.path.abspath(os.path.join(root, pathname))
+        Exception.__init__(self, "No such file: %s" % path)
+
+class EmptyCommit(Exception):
+    def __init__(self):
+        Exception.__init__(self, "No changes to commit")
+
+
+def new():
+    return VCS()
+
+class VCS(object):
+    """
+    This class implements a 'no-vcs' interface.
+
+    Support for other VCSs can be added by subclassing this class, and
+    overriding methods _vcs_*() with code appropriate for your VCS.
+    
+    The methods _u_*() are utility methods available to the _vcs_*()
+    methods.
+    """
+    name = "None"
+    client = "" # command-line tool for _u_invoke_client
+    versioned = False
+    def __init__(self, paranoid=False, encoding=sys.getdefaultencoding()):
+        self.paranoid = paranoid
+        self.verboseInvoke = False
+        self.rootdir = None
+        self._duplicateBasedir = None
+        self._duplicateDirname = None
+        self.encoding = encoding
+    def __del__(self):
+        self.cleanup()
+
+    def _vcs_help(self):
+        """
+        Return the command help string.
+        (Allows a simple test to see if the client is installed.)
+        """
+        pass
+    def _vcs_detect(self, path=None):
+        """
+        Detect whether a directory is revision controlled with this VCS.
+        """
+        return True
+    def _vcs_root(self, path):
+        """
+        Get the VCS root.  This is the default working directory for
+        future invocations.  You would normally set this to the root
+        directory for your VCS.
+        """
+        if os.path.isdir(path)==False:
+            path = os.path.dirname(path)
+            if path == "":
+                path = os.path.abspath(".")
+        return path
+    def _vcs_init(self, path):
+        """
+        Begin versioning the tree based at path.
+        """
+        pass
+    def _vcs_cleanup(self):
+        """
+        Remove any cruft that _vcs_init() created outside of the
+        versioned tree.
+        """
+        pass
+    def _vcs_get_user_id(self):
+        """
+        Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
+        If the VCS has not been configured with a username, return None.
+        """
+        return None
+    def _vcs_set_user_id(self, value):
+        """
+        Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>").
+        This is run if the VCS has not been configured with a usename, so
+        that commits will have a reasonable FROM value.
+        """
+        raise SettingIDnotSupported
+    def _vcs_add(self, path):
+        """
+        Add the already created file at path to version control.
+        """
+        pass
+    def _vcs_remove(self, path):
+        """
+        Remove the file at path from version control.  Optionally
+        remove the file from the filesystem as well.
+        """
+        pass
+    def _vcs_update(self, path):
+        """
+        Notify the versioning system of changes to the versioned file
+        at path.
+        """
+        pass
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
+        """
+        Get the file contents as they were in a given revision.
+        Revision==None specifies the current revision.
+        """
+        assert revision == None, \
+            "The %s VCS does not support revision specifiers" % self.name
+        if binary == False:
+            f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding)
+        else:
+            f = open(os.path.join(self.rootdir, path), "rb")
+        contents = f.read()
+        f.close()
+        return contents
+    def _vcs_duplicate_repo(self, directory, revision=None):
+        """
+        Get the repository as it was in a given revision.
+        revision==None specifies the current revision.
+        dir specifies a directory to create the duplicate in.
+        """
+        shutil.copytree(self.rootdir, directory, True)
+    def _vcs_commit(self, commitfile, allow_empty=False):
+        """
+        Commit the current working directory, using the contents of
+        commitfile as the comment.  Return the name of the old
+        revision (or None if commits are not supported).
+        
+        If allow_empty == False, raise EmptyCommit if there are no
+        changes to commit.
+        """
+        return None
+    def _vcs_revision_id(self, index):
+        """
+        Return the name of the <index>th revision.  Index will be an
+        integer (possibly <= 0).  The choice of which branch to follow
+        when crossing branches/merges is not defined.
+
+        Return None if revision IDs are not supported, or if the
+        specified revision does not exist.
+        """
+        return None
+    def installed(self):
+        try:
+            self._vcs_help()
+            return True
+        except OSError, e:
+            if e.errno == errno.ENOENT:
+                return False
+        except CommandError:
+            return False
+    def detect(self, path="."):
+        """
+        Detect whether a directory is revision controlled with this VCS.
+        """
+        return self._vcs_detect(path)
+    def root(self, path):
+        """
+        Set the root directory to the path's VCS root.  This is the
+        default working directory for future invocations.
+        """
+        self.rootdir = self._vcs_root(path)
+    def init(self, path):
+        """
+        Begin versioning the tree based at path.
+        Also roots the vcs at path.
+        """
+        if os.path.isdir(path)==False:
+            path = os.path.dirname(path)
+        self._vcs_init(path)
+        self.root(path)
+    def cleanup(self):
+        self._vcs_cleanup()
+    def get_user_id(self):
+        """
+        Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
+        If the VCS has not been configured with a username, return the user's
+        id.  You can override the automatic lookup procedure by setting the
+        VCS.user_id attribute to a string of your choice.
+        """
+        if hasattr(self, "user_id"):
+            if self.user_id != None:
+                return self.user_id
+        id = self._vcs_get_user_id()
+        if id == None:
+            name = self._u_get_fallback_username()
+            email = self._u_get_fallback_email()
+            id = self._u_create_id(name, email)
+            print >> sys.stderr, "Guessing id '%s'" % id
+            try:
+                self.set_user_id(id)
+            except SettingIDnotSupported:
+                pass
+        return id
+    def set_user_id(self, value):
+        """
+        Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>").
+        This is run if the VCS has not been configured with a usename, so
+        that commits will have a reasonable FROM value.
+        """
+        self._vcs_set_user_id(value)
+    def add(self, path):
+        """
+        Add the already created file at path to version control.
+        """
+        self._vcs_add(self._u_rel_path(path))
+    def remove(self, path):
+        """
+        Remove a file from both version control and the filesystem.
+        """
+        self._vcs_remove(self._u_rel_path(path))
+        if os.path.exists(path):
+            os.remove(path)
+    def recursive_remove(self, dirname):
+        """
+        Remove a file/directory and all its decendents from both
+        version control and the filesystem.
+        """
+        if not os.path.exists(dirname):
+            raise NoSuchFile(dirname)
+        for dirpath,dirnames,filenames in os.walk(dirname, topdown=False):
+            filenames.extend(dirnames)
+            for path in filenames:
+                fullpath = os.path.join(dirpath, path)
+                if os.path.exists(fullpath) == False:
+                    continue
+                self._vcs_remove(self._u_rel_path(fullpath))
+        if os.path.exists(dirname):
+            shutil.rmtree(dirname)
+    def update(self, path):
+        """
+        Notify the versioning system of changes to the versioned file
+        at path.
+        """
+        self._vcs_update(self._u_rel_path(path))
+    def get_file_contents(self, path, revision=None, allow_no_vcs=False, binary=False):
+        """
+        Get the file as it was in a given revision.
+        Revision==None specifies the current revision.
+        """
+        if not os.path.exists(path):
+            raise NoSuchFile(path)
+        if self._use_vcs(path, allow_no_vcs):
+            relpath = self._u_rel_path(path)
+            contents = self._vcs_get_file_contents(relpath,revision,binary=binary)
+        else:
+            f = codecs.open(path, "r", self.encoding)
+            contents = f.read()
+            f.close()
+        return contents
+    def set_file_contents(self, path, contents, allow_no_vcs=False, binary=False):
+        """
+        Set the file contents under version control.
+        """
+        add = not os.path.exists(path)
+        if binary == False:
+            f = codecs.open(path, "w", self.encoding)
+        else:
+            f = open(path, "wb")
+        f.write(contents)
+        f.close()
+        
+        if self._use_vcs(path, allow_no_vcs):
+            if add:
+                self.add(path)
+            else:
+                self.update(path)
+    def mkdir(self, path, allow_no_vcs=False, check_parents=True):
+        """
+        Create (if neccessary) a directory at path under version
+        control.
+        """
+        if check_parents == True:
+            parent = os.path.dirname(path)
+            if not os.path.exists(parent): # recurse through parents
+                self.mkdir(parent, allow_no_vcs, check_parents)
+        if not os.path.exists(path):
+            os.mkdir(path)
+            if self._use_vcs(path, allow_no_vcs):
+                self.add(path)
+        else:
+            assert os.path.isdir(path)
+            if self._use_vcs(path, allow_no_vcs):
+                #self.update(path)# Don't update directories.  Changing files
+                pass              # underneath them should be sufficient.
+                
+    def duplicate_repo(self, revision=None):
+        """
+        Get the repository as it was in a given revision.
+        revision==None specifies the current revision.
+        Return the path to the arbitrary directory at the base of the new repo.
+        """
+        # Dirname in Baseir to protect against simlink attacks.
+        if self._duplicateBasedir == None:
+            self._duplicateBasedir = tempfile.mkdtemp(prefix='BEvcs')
+            self._duplicateDirname = \
+                os.path.join(self._duplicateBasedir, "duplicate")
+            self._vcs_duplicate_repo(directory=self._duplicateDirname,
+                                     revision=revision)
+        return self._duplicateDirname
+    def remove_duplicate_repo(self):
+        """
+        Clean up a duplicate repo created with duplicate_repo().
+        """
+        if self._duplicateBasedir != None:
+            shutil.rmtree(self._duplicateBasedir)
+            self._duplicateBasedir = None
+            self._duplicateDirname = None
+    def commit(self, summary, body=None, allow_empty=False):
+        """
+        Commit the current working directory, with a commit message
+        string summary and body.  Return the name of the old revision
+        (or None if versioning is not supported).
+        
+        If allow_empty == False (the default), raise EmptyCommit if
+        there are no changes to commit.
+        """
+        summary = summary.strip()+'\n'
+        if body is not None:
+            summary += '\n' + body.strip() + '\n'
+        descriptor, filename = tempfile.mkstemp()
+        revision = None
+        try:
+            temp_file = os.fdopen(descriptor, 'wb')
+            temp_file.write(summary)
+            temp_file.flush()
+            self.precommit()
+            revision = self._vcs_commit(filename, allow_empty=allow_empty)
+            temp_file.close()
+            self.postcommit()
+        finally:
+            os.remove(filename)
+        return revision
+    def precommit(self):
+        """
+        Executed before all attempted commits.
+        """
+        pass
+    def postcommit(self):
+        """
+        Only executed after successful commits.
+        """
+        pass
+    def revision_id(self, index=None):
+        """
+        Return the name of the <index>th revision.  The choice of
+        which branch to follow when crossing branches/merges is not
+        defined.
+
+        Return None if index==None, revision IDs are not supported, or
+        if the specified revision does not exist.
+        """
+        if index == None:
+            return None
+        return self._vcs_revision_id(index)
+    def _u_any_in_string(self, list, string):
+        """
+        Return True if any of the strings in list are in string.
+        Otherwise return False.
+        """
+        for list_string in list:
+            if list_string in string:
+                return True
+        return False
+    def _u_invoke(self, args, stdin=None, expect=(0,), cwd=None):
+        """
+        expect should be a tuple of allowed exit codes.  cwd should be
+        the directory from which the command will be executed.
+        """
+        if cwd == None:
+            cwd = self.rootdir
+        if self.verboseInvoke == True:
+            print >> sys.stderr, "%s$ %s" % (cwd, " ".join(args))
+        try :
+            if sys.platform != "win32":
+                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd)
+            else:
+                # win32 don't have os.execvp() so have to run command in a shell
+                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, 
+                          shell=True, cwd=cwd)
+        except OSError, e :
+            raise CommandError(args, status=e.args[0], stdout="", stderr=e)
+        output,error = q.communicate(input=stdin)
+        status = q.wait()
+        if self.verboseInvoke == True:
+            print >> sys.stderr, "%d\n%s%s" % (status, output, error)
+        if status not in expect:
+            raise CommandError(args, status, output, error)
+        return status, output, error
+    def _u_invoke_client(self, *args, **kwargs):
+        directory = kwargs.get('directory',None)
+        expect = kwargs.get('expect', (0,))
+        stdin = kwargs.get('stdin', None)
+        cl_args = [self.client]
+        cl_args.extend(args)
+        return self._u_invoke(cl_args, stdin=stdin,expect=expect,cwd=directory)
+    def _u_search_parent_directories(self, path, filename):
+        """
+        Find the file (or directory) named filename in path or in any
+        of path's parents.
+        
+        e.g.
+          search_parent_directories("/a/b/c", ".be")
+        will return the path to the first existing file from
+          /a/b/c/.be
+          /a/b/.be
+          /a/.be
+          /.be
+        or None if none of those files exist.
+        """
+        return search_parent_directories(path, filename)
+    def _use_vcs(self, path, allow_no_vcs):
+        """
+        Try and decide if _vcs_add/update/mkdir/etc calls will
+        succeed.  Returns True is we think the vcs_call would
+        succeeed, and False otherwise.
+        """
+        use_vcs = True
+        exception = None
+        if self.rootdir != None:
+            if self.path_in_root(path) == False:
+                use_vcs = False
+                exception = PathNotInRoot(path, self.rootdir)
+        else:
+            use_vcs = False
+            exception = VCSnotRooted
+        if use_vcs == False and allow_no_vcs==False:
+            raise exception
+        return use_vcs
+    def path_in_root(self, path, root=None):
+        """
+        Return the relative path to path from root.
+        >>> vcs = new()
+        >>> vcs.path_in_root("/a.b/c/.be", "/a.b/c")
+        True
+        >>> vcs.path_in_root("/a.b/.be", "/a.b/c")
+        False
+        """
+        if root == None:
+            if self.rootdir == None:
+                raise VCSnotRooted
+            root = self.rootdir
+        path = os.path.abspath(path)
+        absRoot = os.path.abspath(root)
+        absRootSlashedDir = os.path.join(absRoot,"")
+        if not path.startswith(absRootSlashedDir):
+            return False
+        return True
+    def _u_rel_path(self, path, root=None):
+        """
+        Return the relative path to path from root.
+        >>> vcs = new()
+        >>> vcs._u_rel_path("/a.b/c/.be", "/a.b/c")
+        '.be'
+        """
+        if root == None:
+            if self.rootdir == None:
+                raise VCSnotRooted
+            root = self.rootdir
+        path = os.path.abspath(path)
+        absRoot = os.path.abspath(root)
+        absRootSlashedDir = os.path.join(absRoot,"")
+        if not path.startswith(absRootSlashedDir):
+            raise PathNotInRoot(path, absRootSlashedDir)
+        assert path != absRootSlashedDir, \
+            "file %s == root directory %s" % (path, absRootSlashedDir)
+        relpath = path[len(absRootSlashedDir):]
+        return relpath
+    def _u_abspath(self, path, root=None):
+        """
+        Return the absolute path from a path realtive to root.
+        >>> vcs = new()
+        >>> vcs._u_abspath(".be", "/a.b/c")
+        '/a.b/c/.be'
+        """
+        if root == None:
+            assert self.rootdir != None, "VCS not rooted"
+            root = self.rootdir
+        return os.path.abspath(os.path.join(root, path))
+    def _u_create_id(self, name, email=None):
+        """
+        >>> vcs = new()
+        >>> vcs._u_create_id("John Doe", "jdoe@example.com")
+        'John Doe <jdoe@example.com>'
+        >>> vcs._u_create_id("John Doe")
+        'John Doe'
+        """
+        assert len(name) > 0
+        if email == None or len(email) == 0:
+            return name
+        else:
+            return "%s <%s>" % (name, email)
+    def _u_parse_id(self, value):
+        """
+        >>> vcs = new()
+        >>> vcs._u_parse_id("John Doe <jdoe@example.com>")
+        ('John Doe', 'jdoe@example.com')
+        >>> vcs._u_parse_id("John Doe")
+        ('John Doe', None)
+        >>> try:
+        ...     vcs._u_parse_id("John Doe <jdoe@example.com><what?>")
+        ... except AssertionError:
+        ...     print "Invalid match"
+        Invalid match
+        """
+        emailexp = re.compile("(.*) <([^>]*)>(.*)")
+        match = emailexp.search(value)
+        if match == None:
+            email = None
+            name = value
+        else:
+            assert len(match.groups()) == 3
+            assert match.groups()[2] == "", match.groups()
+            email = match.groups()[1]
+            name = match.groups()[0]
+        assert name != None
+        assert len(name) > 0
+        return (name, email)
+    def _u_get_fallback_username(self):
+        name = None
+        for envariable in ["LOGNAME", "USERNAME"]:
+            if os.environ.has_key(envariable):
+                name = os.environ[envariable]
+                break
+        assert name != None
+        return name
+    def _u_get_fallback_email(self):
+        hostname = gethostname()
+        name = self._u_get_fallback_username()
+        return "%s@%s" % (name, hostname)
+    def _u_parse_commitfile(self, commitfile):
+        """
+        Split the commitfile created in self.commit() back into
+        summary and header lines.
+        """
+        f = codecs.open(commitfile, "r", self.encoding)
+        summary = f.readline()
+        body = f.read()
+        body.lstrip('\n')
+        if len(body) == 0:
+            body = None
+        f.close()
+        return (summary, body)
+        
+\f
+def setup_vcs_test_fixtures(testcase):
+    """Set up test fixtures for VCS test case."""
+    testcase.vcs = testcase.Class()
+    testcase.dir = Dir()
+    testcase.dirname = testcase.dir.path
+
+    vcs_not_supporting_uninitialized_user_id = []
+    vcs_not_supporting_set_user_id = ["None", "hg"]
+    testcase.vcs_supports_uninitialized_user_id = (
+        testcase.vcs.name not in vcs_not_supporting_uninitialized_user_id)
+    testcase.vcs_supports_set_user_id = (
+        testcase.vcs.name not in vcs_not_supporting_set_user_id)
+
+    if not testcase.vcs.installed():
+        testcase.fail(
+            "%(name)s VCS not found" % vars(testcase.Class))
+
+    if testcase.Class.name != "None":
+        testcase.failIf(
+            testcase.vcs.detect(testcase.dirname),
+            "Detected %(name)s VCS before initialising"
+                % vars(testcase.Class))
+
+    testcase.vcs.init(testcase.dirname)
+
+
+class VCSTestCase(unittest.TestCase):
+    """Test cases for base VCS class."""
+
+    Class = VCS
+
+    def __init__(self, *args, **kwargs):
+        super(VCSTestCase, self).__init__(*args, **kwargs)
+        self.dirname = None
+
+    def setUp(self):
+        super(VCSTestCase, self).setUp()
+        setup_vcs_test_fixtures(self)
+
+    def tearDown(self):
+        del(self.vcs)
+        super(VCSTestCase, self).tearDown()
+
+    def full_path(self, rel_path):
+        return os.path.join(self.dirname, rel_path)
+
+
+class VCS_init_TestCase(VCSTestCase):
+    """Test cases for VCS.init method."""
+
+    def test_detect_should_succeed_after_init(self):
+        """Should detect VCS in directory after initialization."""
+        self.failUnless(
+            self.vcs.detect(self.dirname),
+            "Did not detect %(name)s VCS after initialising"
+                % vars(self.Class))
+
+    def test_vcs_rootdir_in_specified_root_path(self):
+        """VCS root directory should be in specified root path."""
+        rp = os.path.realpath(self.vcs.rootdir)
+        dp = os.path.realpath(self.dirname)
+        vcs_name = self.Class.name
+        self.failUnless(
+            dp == rp or rp == None,
+            "%(vcs_name)s VCS root in wrong dir (%(dp)s %(rp)s)" % vars())
+
+
+class VCS_get_user_id_TestCase(VCSTestCase):
+    """Test cases for VCS.get_user_id method."""
+
+    def test_gets_existing_user_id(self):
+        """Should get the existing user ID."""
+        if not self.vcs_supports_uninitialized_user_id:
+            return
+
+        user_id = self.vcs.get_user_id()
+        self.failUnless(
+            user_id is not None,
+            "unable to get a user id")
+
+
+class VCS_set_user_id_TestCase(VCSTestCase):
+    """Test cases for VCS.set_user_id method."""
+
+    def setUp(self):
+        super(VCS_set_user_id_TestCase, self).setUp()
+
+        if self.vcs_supports_uninitialized_user_id:
+            self.prev_user_id = self.vcs.get_user_id()
+        else:
+            self.prev_user_id = "Uninitialized identity <bogus@example.org>"
+
+        if self.vcs_supports_set_user_id:
+            self.test_new_user_id = "John Doe <jdoe@example.com>"
+            self.vcs.set_user_id(self.test_new_user_id)
+
+    def tearDown(self):
+        if self.vcs_supports_set_user_id:
+            self.vcs.set_user_id(self.prev_user_id)
+        super(VCS_set_user_id_TestCase, self).tearDown()
+
+    def test_raises_error_in_unsupported_vcs(self):
+        """Should raise an error in a VCS that doesn't support it."""
+        if self.vcs_supports_set_user_id:
+            return
+        self.assertRaises(
+            SettingIDnotSupported,
+            self.vcs.set_user_id, "foo")
+
+    def test_updates_user_id_in_supporting_vcs(self):
+        """Should update the user ID in an VCS that supports it."""
+        if not self.vcs_supports_set_user_id:
+            return
+        user_id = self.vcs.get_user_id()
+        self.failUnlessEqual(
+            self.test_new_user_id, user_id,
+            "user id not set correctly (expected %s, got %s)"
+                % (self.test_new_user_id, user_id))
+
+
+def setup_vcs_revision_test_fixtures(testcase):
+    """Set up revision test fixtures for VCS test case."""
+    testcase.test_dirs = ['a', 'a/b', 'c']
+    for path in testcase.test_dirs:
+        testcase.vcs.mkdir(testcase.full_path(path))
+
+    testcase.test_files = ['a/text', 'a/b/text']
+
+    testcase.test_contents = {
+        'rev_1': "Lorem ipsum",
+        'uncommitted': "dolor sit amet",
+        }
+
+
+class VCS_mkdir_TestCase(VCSTestCase):
+    """Test cases for VCS.mkdir method."""
+
+    def setUp(self):
+        super(VCS_mkdir_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_mkdir_TestCase, self).tearDown()
+
+    def test_mkdir_creates_directory(self):
+        """Should create specified directory in filesystem."""
+        for path in self.test_dirs:
+            full_path = self.full_path(path)
+            self.failUnless(
+                os.path.exists(full_path),
+                "path %(full_path)s does not exist" % vars())
+
+
+class VCS_commit_TestCase(VCSTestCase):
+    """Test cases for VCS.commit method."""
+
+    def setUp(self):
+        super(VCS_commit_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_commit_TestCase, self).tearDown()
+
+    def test_file_contents_as_specified(self):
+        """Should set file contents as specified."""
+        test_contents = self.test_contents['rev_1']
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(full_path, test_contents)
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(test_contents, current_contents)
+
+    def test_file_contents_as_committed(self):
+        """Should have file contents as specified after commit."""
+        test_contents = self.test_contents['rev_1']
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(full_path, test_contents)
+            revision = self.vcs.commit("Initial file contents.")
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(test_contents, current_contents)
+
+    def test_file_contents_as_set_when_uncommitted(self):
+        """Should set file contents as specified after commit."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial file contents.")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(
+                self.test_contents['uncommitted'], current_contents)
+
+    def test_revision_file_contents_as_committed(self):
+        """Should get file contents as committed to specified revision."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial file contents.")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            committed_contents = self.vcs.get_file_contents(
+                full_path, revision)
+            self.failUnlessEqual(
+                self.test_contents['rev_1'], committed_contents)
+
+    def test_revision_id_as_committed(self):
+        """Check for compatibility between .commit() and .revision_id()"""
+        if not self.vcs.versioned:
+            self.failUnlessEqual(self.vcs.revision_id(5), None)
+            return
+        committed_revisions = []
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial %s contents." % path)
+            committed_revisions.append(revision)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            revision = self.vcs.commit("Altered %s contents." % path)
+            committed_revisions.append(revision)
+        for i,revision in enumerate(committed_revisions):
+            self.failUnlessEqual(self.vcs.revision_id(i), revision)
+            i += -len(committed_revisions) # check negative indices
+            self.failUnlessEqual(self.vcs.revision_id(i), revision)
+        i = len(committed_revisions)
+        self.failUnlessEqual(self.vcs.revision_id(i), None)
+        self.failUnlessEqual(self.vcs.revision_id(-i-1), None)
+
+    def test_revision_id_as_committed(self):
+        """Check revision id before first commit"""
+        if not self.vcs.versioned:
+            self.failUnlessEqual(self.vcs.revision_id(5), None)
+            return
+        committed_revisions = []
+        for path in self.test_files:
+            self.failUnlessEqual(self.vcs.revision_id(0), None)
+
+
+class VCS_duplicate_repo_TestCase(VCSTestCase):
+    """Test cases for VCS.duplicate_repo method."""
+
+    def setUp(self):
+        super(VCS_duplicate_repo_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        self.vcs.remove_duplicate_repo()
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_duplicate_repo_TestCase, self).tearDown()
+
+    def test_revision_file_contents_as_committed(self):
+        """Should match file contents as committed to specified revision."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Commit current status")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            dup_repo_path = self.vcs.duplicate_repo(revision)
+            dup_file_path = os.path.join(dup_repo_path, path)
+            dup_file_contents = file(dup_file_path, 'rb').read()
+            self.failUnlessEqual(
+                self.test_contents['rev_1'], dup_file_contents)
+            self.vcs.remove_duplicate_repo()
+
+
+def make_vcs_testcase_subclasses(vcs_class, namespace):
+    """Make VCSTestCase subclasses for vcs_class in the namespace."""
+    vcs_testcase_classes = [
+        c for c in (
+            ob for ob in globals().values() if isinstance(ob, type))
+        if issubclass(c, VCSTestCase)]
+
+    for base_class in vcs_testcase_classes:
+        testcase_class_name = vcs_class.__name__ + base_class.__name__
+        testcase_class_bases = (base_class,)
+        testcase_class_dict = dict(base_class.__dict__)
+        testcase_class_dict['Class'] = vcs_class
+        testcase_class = type(
+            testcase_class_name, testcase_class_bases, testcase_class_dict)
+        setattr(namespace, testcase_class_name, testcase_class)
+
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/interfaces/web/Bugs-Everywhere-Web/libbe/version.py b/interfaces/web/Bugs-Everywhere-Web/libbe/version.py
new file mode 100644 (file)
index 0000000..f8eebbd
--- /dev/null
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Store version info for this BE installation.  By default, use the
+bzr-generated information in _version.py, but allow manual overriding
+by setting _VERSION.  This allows support of both the "I don't want to
+be bothered setting version strings" and the "I want complete control
+over the version strings" workflows.
+"""
+
+import libbe._version as _version
+
+# Manually set a version string (optional, defaults to bzr revision id)
+#_VERSION = "1.2.3"
+
+def version(verbose=False):
+    """
+    Returns the version string for this BE installation.  If
+    verbose==True, the string will include extra lines with more
+    detail (e.g. bzr branch nickname, etc.).
+    """
+    if "_VERSION" in globals():
+        string = _VERSION
+    else:
+        string = _version.version_info["revision_id"]
+    if verbose == True:
+        string += ("\n"
+                   "revision: %(revno)d\n"
+                   "nick: %(branch_nick)s\n"
+                   "revision id: %(revision_id)s"
+                   % _version.version_info)
+    return string
+
+if __name__ == "__main__":
+    print version(verbose=True)
index 335f92fd76ffff0a4242f778a93f1d9c2a2bc8ae..a740117e32b5600c1846f84275e7651f47e34d10 100644 (file)
@@ -25,11 +25,10 @@ followed by a blank line.
 import base64
 import email.utils
 from libbe.encoding import get_encoding, set_IO_stream_encodings
+from libbe.utility import time_to_str
 from mailbox import mbox, Message  # the mailbox people really want an on-disk copy
-from time import asctime, gmtime
+from time import asctime, gmtime, mktime
 import types
-from xml.sax import make_parser
-from xml.sax.handler import ContentHandler
 from xml.sax.saxutils import escape
 
 DEFAULT_ENCODING = get_encoding()
@@ -37,14 +36,34 @@ set_IO_stream_encodings(DEFAULT_ENCODING)
 
 KNOWN_IDS = []
 
+def normalize_email_address(address):
+    """
+    Standardize whitespace, etc.
+    """
+    return email.utils.formataddr(email.utils.parseaddr(address))
+
+def normalize_RFC_2822_date(date):
+    """
+    Some email clients write non-RFC 2822-compliant date tags like:
+      Fri, 18 Sep 2009 08:49:02 -0400 (EDT)
+    with the non-standard (EDT) timezone name.  This funtion attempts
+    to deal with such inconsistencies.
+    """
+    time_tuple = email.utils.parsedate(date)
+    assert time_tuple != None, \
+        'unparsable date: "%s"' % date
+    return time_to_str(mktime(time_tuple))
+
 def comment_message_to_xml(message, fields=None):
     if fields == None:
         fields = {}
     new_fields = {}
     new_fields[u'alt-id'] = message[u'message-id']
     new_fields[u'in-reply-to'] = message[u'in-reply-to']
-    new_fields[u'from'] = message[u'from']
+    new_fields[u'author'] = normalize_email_address(message[u'from'])
     new_fields[u'date'] = message[u'date']
+    if new_fields[u'date'] != None:
+        new_fields[u'date'] = normalize_RFC_2822_date(new_fields[u'date'])
     new_fields[u'content-type'] = message.get_content_type()
     for k,v in new_fields.items():
         if v != None and type(v) != types.UnicodeType:
@@ -67,25 +86,27 @@ def comment_message_to_xml(message, fields=None):
                 fields[u'in-reply-to'] = refs[0] # default to the first
     else: # check for mutliple in-reply-to references.
         refs = fields[u'in-reply-to'].split()
+        found_ref = False
         for ref in refs: # search for a known reference id.
             if ref in KNOWN_IDS:
                 fields[u'in-reply-to'] = ref
+                found_ref = True
                 break
-        if fields[u'in-reply-to'] == None and len(refs) > 0:
+        if found_ref == False and len(refs) > 0:
             fields[u'in-reply-to'] = refs[0] # default to the first
 
-    if fields['alt-id'] != None:
-        KNOWN_IDS.append(fields['alt-id'])
+    if fields[u'alt-id'] != None:
+        KNOWN_IDS.append(fields[u'alt-id'])
 
     if message.is_multipart():
         ret = []
         alt_id = fields[u'alt-id']
-        from_str = fields[u'from']
+        from_str = fields[u'author']
         date = fields[u'date']
         for m in message.walk():
             if m == message:
                 continue
-            fields[u'from'] = from_str
+            fields[u'author'] = from_str
             fields[u'date'] = date
             if len(ret) > 0: # we've added one part already
                 fields.pop(u'alt-id') # don't pass alt-id to other parts
index ea77c346e7823be100162ede1caf53601a6cf6fb..c63044708ae7855080c344fe9b5970694261b1b6 100644 (file)
@@ -129,7 +129,7 @@ class Comment (LimitedAttrDict):
               u"alt-id",
               u"short-name",
               u"in-reply-to",
-              u"from",
+              u"author",
               u"date",
               u"content-type",
               u"body"]
@@ -137,7 +137,7 @@ class Comment (LimitedAttrDict):
         if bug == None:
             bug = Bug()
             bug[u"uuid"] = u"no-uuid"
-        name,addr = email.utils.parseaddr(self["from"])
+        name,addr = email.utils.parseaddr(self["author"])
         print "From %s %s" % (addr, rfc2822_to_asctime(self["date"]))
         if "uuid" in self:     id = self["uuid"]
         elif "alt-id" in self: id = self["alt-id"]
@@ -145,7 +145,7 @@ class Comment (LimitedAttrDict):
         if id != None:
             print "Message-ID: <%s@%s>" % (id, DEFAULT_DOMAIN)
         print "Date: %s" % self["date"]
-        print "From: %s" % self["from"]
+        print "From: %s" % self["author"]
         subject = ""
         if "short-name" in self:
             subject += self["short-name"]+u": "
index 2f45aa924fa497ff23ef0e84051be6bc89eb536f..ab551729da670de122e9de31f91bd155710a095b 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+GNU Arch (tla) backend.
+"""
+
 import codecs
 import os
 import re
@@ -26,10 +30,11 @@ import time
 import unittest
 import doctest
 
-import config
 from beuuid import uuid_gen
-import rcs
-from rcs import RCS
+import config
+import vcs
+
+
 
 DEFAULT_CLIENT = "tla"
 
@@ -38,7 +43,7 @@ client = config.get_val("arch_client", default=DEFAULT_CLIENT)
 def new():
     return Arch()
 
-class Arch(RCS):
+class Arch(vcs.VCS):
     name = "Arch"
     client = client
     versioned = True
@@ -48,21 +53,25 @@ class Arch(RCS):
     _project_name = None
     _tmp_project = False
     _arch_paramdir = os.path.expanduser("~/.arch-params")
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         """Detect whether a directory is revision-controlled using Arch"""
         if self._u_search_parent_directories(path, "{arch}") != None :
             config.set_val("arch_client", client)
             return True
         return False
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._create_archive(path)
         self._create_project(path)
         self._add_project_code(path)
     def _create_archive(self, path):
-        # Create a new archive
+        """
+        Create a temporary Arch archive in the directory PATH.  This
+        archive will be removed by
+          __del__->cleanup->_vcs_cleanup->_remove_archive
+        """
         # http://regexps.srparish.net/tutorial-tla/new-archive.html#Creating_a_New_Archive
         assert self._archive_name == None
         id = self.get_user_id()
@@ -103,7 +112,7 @@ class Arch(RCS):
         """
         Create a temporary Arch project in the directory PATH.  This
         project will be removed by
-          __del__->cleanup->_rcs_cleanup->_remove_project
+          __del__->cleanup->_vcs_cleanup->_remove_project
         """
         # http://mwolson.org/projects/GettingStartedWithArch.html
         # http://regexps.srparish.net/tutorial-tla/new-project.html#Starting_a_New_Project
@@ -159,13 +168,13 @@ class Arch(RCS):
         self._adjust_naming_conventions(path)
         self._invoke_client("import", "--summary", "Began versioning",
                             directory=path)
-    def _rcs_cleanup(self):
+    def _vcs_cleanup(self):
         if self._tmp_project == True:
             self._remove_project()
         if self._tmp_archive == True:
             self._remove_archive()
 
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         if not os.path.isdir(path):
             dirname = os.path.dirname(path)
         else:
@@ -176,7 +185,6 @@ class Arch(RCS):
         self._get_archive_project_name(root)
 
         return root
-
     def _get_archive_name(self, root):
         status,output,error = self._u_invoke_client("archives")
         lines = output.split('\n')
@@ -188,7 +196,6 @@ class Arch(RCS):
             if os.path.realpath(location) == os.path.realpath(root):
                 self._archive_name = archive
         assert self._archive_name != None
-
     def _get_archive_project_name(self, root):
         # get project names
         status,output,error = self._u_invoke_client("tree-version", directory=root)
@@ -197,7 +204,7 @@ class Arch(RCS):
         archive_name,project_name = output.rstrip('\n').split('/')
         self._archive_name = archive_name
         self._project_name = project_name
-    def _rcs_get_user_id(self):
+    def _vcs_get_user_id(self):
         try:
             status,output,error = self._u_invoke_client('my-id')
             return output.rstrip('\n')
@@ -206,9 +213,9 @@ class Arch(RCS):
                 return None
             else:
                 raise
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         self._u_invoke_client('my-id', value)
-    def _rcs_add(self, path):
+    def _vcs_add(self, path):
         self._u_invoke_client("add-id", path)
         realpath = os.path.realpath(self._u_abspath(path))
         pathAdded = realpath in self._list_added(self.rootdir)
@@ -237,14 +244,14 @@ class Arch(RCS):
         self._add_dir_rule(rule, os.path.dirname(path), self.rootdir)
         if os.path.realpath(path) not in self._list_added(self.rootdir):
             raise CantAddFile(path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         if not '.arch-ids' in path:
             self._u_invoke_client("delete-id", path)
-    def _rcs_update(self, path):
+    def _vcs_update(self, path):
         pass
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision, binary=binary)
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
         else:
             status,output,error = \
                 self._invoke_client("file-find", path, revision)
@@ -254,18 +261,18 @@ class Arch(RCS):
             contents = f.read()
             f.close()
             return contents
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision == None:
-            RCS._rcs_duplicate_repo(self, directory, revision)
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             status,output,error = \
                 self._u_invoke_client("get", revision,directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         if allow_empty == False:
             # arch applies empty commits without complaining, so check first
             status,output,error = self._u_invoke_client("changes",expect=(0,1))
             if status == 0:
-                raise rcs.EmptyCommit()
+                raise vcs.EmptyCommit()
         summary,body = self._u_parse_commitfile(commitfile)
         args = ["commit", "--summary", summary]
         if body != None:
@@ -281,6 +288,16 @@ class Arch(RCS):
         assert revpath.startswith(self._archive_project_name()+'--')
         revision = revpath[len(self._archive_project_name()+'--'):]
         return revpath
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("logs")
+        logs = output.splitlines()
+        first_log = logs.pop(0)
+        assert first_log == "base-0", first_log
+        try:
+            log = logs[index]
+        except IndexError:
+            return None
+        return "%s--%s" % (self._archive_project_name(), log)
 
 class CantAddFile(Exception):
     def __init__(self, file):
@@ -289,7 +306,7 @@ class CantAddFile(Exception):
 
 
 \f
-rcs.make_rcs_testcase_subclasses(Arch, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Arch, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index bc47208210a88f7b0d65a64d9a4cb8033a4eba71..490ed6243044ceb65e766dae31abae4a499619aa 100644 (file)
@@ -13,6 +13,7 @@
 # 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.
+
 """
 Backwards compatibility support for Python 2.4.  Once people give up
 on 2.4 ;), the uuid call should be merged into bugdir.py
@@ -20,6 +21,7 @@ on 2.4 ;), the uuid call should be merged into bugdir.py
 
 import unittest
 
+
 try:
     from uuid import uuid4 # Python >= 2.5
     def uuid_gen():
index c1e54815434e802f27b69ca8deeeec114ebd9840..fd30ff74a74e67c38285273f366755ef0e3f1f14 100644 (file)
 # 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.
+
+"""
+Define the Bug class for representing bugs.
+"""
+
 import os
 import os.path
 import errno
@@ -33,6 +38,11 @@ import comment
 import utility
 
 
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
+
 ### Define and describe valid bug categories
 # Use a tuple of (category, description) tuples since we don't have
 # ordered dicts in Python yet http://www.python.org/dev/peps/pep-0372/
@@ -216,15 +226,15 @@ class Bug(settings_object.SavedSettingsObject):
     @doc_property(doc="The trunk of the comment tree")
     def comment_root(): return {}
 
-    def _get_rcs(self):
-        if hasattr(self.bugdir, "rcs"):
-            return self.bugdir.rcs
+    def _get_vcs(self):
+        if hasattr(self.bugdir, "vcs"):
+            return self.bugdir.vcs
 
     @Property
-    @cached_property(generator=_get_rcs)
-    @local_property("rcs")
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
     @doc_property(doc="A revision control system instance.")
-    def rcs(): return {}
+    def vcs(): return {}
 
     def __init__(self, bugdir=None, uuid=None, from_disk=False,
                  load_comments=False, summary=None):
@@ -238,17 +248,20 @@ class Bug(settings_object.SavedSettingsObject):
             if uuid == None:
                 self.uuid = uuid_gen()
             self.time = int(time.time()) # only save to second precision
-            if self.rcs != None:
-                self.creator = self.rcs.get_user_id()
+            if self.vcs != None:
+                self.creator = self.vcs.get_user_id()
             self.summary = summary
 
     def __repr__(self):
         return "Bug(uuid=%r)" % self.uuid
 
-    def set_sync_with_disk(self, value):
-        self.sync_with_disk = value
-        for comment in self.comments():
-            comment.set_sync_with_disk(value)
+    def __str__(self):
+        return self.string(shortlist=True)
+
+    def __cmp__(self, other):
+        return cmp_full(self, other)
+
+    # serializing methods
 
     def _setting_attr_string(self, setting):
         value = getattr(self, setting)
@@ -331,43 +344,34 @@ class Bug(settings_object.SavedSettingsObject):
             output = bugout
         return output
 
-    def __str__(self):
-        return self.string(shortlist=True)
+    # methods for saving/loading/acessing settings and properties.
 
-    def __cmp__(self, other):
-        return cmp_full(self, other)
+    def get_path(self, *args):
+        dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["values", "comments"], str(args)
+        return os.path.join(dir, *args)
 
-    def get_path(self, name=None):
-        my_dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
-        if name is None:
-            return my_dir
-        assert name in ["values", "comments"]
-        return os.path.join(my_dir, name)
+    def set_sync_with_disk(self, value):
+        self.sync_with_disk = value
+        for comment in self.comments():
+            comment.set_sync_with_disk(value)
 
     def load_settings(self):
-        self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load settings")
+        self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
         self._setup_saved_settings()
 
-    def load_comments(self, load_full=True):
-        if load_full == True:
-            # Force a complete load of the whole comment tree
-            self.comment_root = self._get_comment_root(load_full=True)
-        else:
-            # Setup for fresh lazy-loading.  Clear _comment_root, so
-            # _get_comment_root returns a fresh version.  Turn of
-            # syncing temporarily so we don't write our blank comment
-            # tree to disk.
-            self.sync_with_disk = False
-            self.comment_root = None
-            self.sync_with_disk = True
-
     def save_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("save settings")
         assert self.summary != None, "Can't save blank bug"
-        
-        self.rcs.mkdir(self.get_path())
+        self.vcs.mkdir(self.get_path())
         path = self.get_path("values")
-        mapfile.map_save(self.rcs, path, self._get_saved_settings())
-        
+        mapfile.map_save(self.vcs, path, self._get_saved_settings())
+
     def save(self):
         """
         Save any loaded contents to disk.  Because of lazy loading of
@@ -378,15 +382,39 @@ class Bug(settings_object.SavedSettingsObject):
         calling this method will just waste time (unless something
         else has been messing with your on-disk files).
         """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
         self.save_settings()
         if len(self.comment_root) > 0:
             comment.saveComments(self)
+        if sync_with_disk == False:
+            self.set_sync_with_disk(False)
+
+    def load_comments(self, load_full=True):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load comments")
+        if load_full == True:
+            # Force a complete load of the whole comment tree
+            self.comment_root = self._get_comment_root(load_full=True)
+        else:
+            # Setup for fresh lazy-loading.  Clear _comment_root, so
+            # _get_comment_root returns a fresh version.  Turn of
+            # syncing temporarily so we don't write our blank comment
+            # tree to disk.
+            self.sync_with_disk = False
+            self.comment_root = None
+            self.sync_with_disk = True
 
     def remove(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("remove")
         self.comment_root.remove()
         path = self.get_path()
-        self.rcs.recursive_remove(path)
+        self.vcs.recursive_remove(path)
     
+    # methods for managing comments
+
     def comments(self):
         for comment in self.comment_root.traverse():
             yield comment
@@ -489,8 +517,12 @@ def cmp_attr(bug_1, bug_2, attr, invert=False):
         return cmp(val_1, val_2)
 
 # alphabetical rankings (a < z)
+cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
+cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
+cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
+cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
 # chronological rankings (newer < older)
 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
 
@@ -512,7 +544,8 @@ def cmp_comments(bug_1, bug_2):
     return 0
 
 DEFAULT_CMP_FULL_CMP_LIST = \
-    (cmp_status,cmp_severity,cmp_assigned,cmp_time,cmp_creator,cmp_comments)
+    (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
+     cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid)
 
 class BugCompoundComparator (object):
     def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
index 6e020ee784bf21f4363cd9d6aa0c7517c437fd9c..c4f0f9192b327035f90c8a88689f60a6044d6ccb 100644 (file)
 # 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.
+
+"""
+Define the BugDir class for representing bug comments.
+"""
+
+import copy
+import errno
 import os
 import os.path
-import errno
+import sys
 import time
-import copy
 import unittest
 import doctest
 
+import bug
+import encoding
 from properties import Property, doc_property, local_property, \
     defaulting_property, checked_property, fn_checked_property, \
     cached_property, primed_property, change_hook_property, \
     settings_property
-import settings_object
 import mapfile
-import bug
-import rcs
-import encoding
+import vcs
+import settings_object
+import upgrade
 import utility
 
 
@@ -62,8 +69,16 @@ class MultipleBugMatches(ValueError):
         self.shortname = shortname
         self.matches = matches
 
+class NoBugMatches(KeyError):
+    def __init__(self, shortname):
+        msg = "No bug matches %s" % shortname
+        KeyError.__init__(self, msg)
+        self.shortname = shortname
 
-TREE_VERSION_STRING = "Bugs Everywhere Tree 1 0\n"
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
 
 
 class BugDir (list, settings_object.SavedSettingsObject):
@@ -99,7 +114,8 @@ class BugDir (list, settings_object.SavedSettingsObject):
     all bugs/comments/etc. that have been loaded into memory.  If
     you've been living in memory and want to move to
     .sync_with_disk==True, but you're not sure if anything has been
-    changed in memoryy, a call to save() is a safe move.
+    changed in memory, a call to save() immediately before the
+    .set_sync_with_disk(True) call is a safe move.
 
     Regardless of .sync_with_disk, a call to .save() will write out
     all the contents that the BugDir instance has loaded into memory.
@@ -107,15 +123,14 @@ class BugDir (list, settings_object.SavedSettingsObject):
     changes, this .save() call will be a waste of time.
 
     The BugDir will only load information from the file system when it
-    loads new bugs/comments that it doesn't already have in memory, or
-    when it explicitly asked to do so (e.g. .load() or
-    __init__(from_disk=True)).
+    loads new settings/bugs/comments that it doesn't already have in
+    memory and .sync_with_disk == True.
 
-    Allow RCS initialization
+    Allow VCS initialization
     ========================
 
     This one is for testing purposes.  Setting it to True allows the
-    BugDir to search for an installed RCS backend and initialize it in
+    BugDir to search for an installed VCS backend and initialize it in
     the root directory.  This is a convenience option for supporting
     tests of versioning functionality (e.g. .duplicate_bugdir).
 
@@ -172,9 +187,9 @@ class BugDir (list, settings_object.SavedSettingsObject):
     def encoding(): return {}
 
     def _setup_user_id(self, user_id):
-        self.rcs.user_id = user_id
+        self.vcs.user_id = user_id
     def _guess_user_id(self):
-        return self.rcs.get_user_id()
+        return self.vcs.get_user_id()
     def _set_user_id(self, old_user_id, new_user_id):
         self._setup_user_id(new_user_id)
         self._prop_save_settings(old_user_id, new_user_id)
@@ -182,7 +197,7 @@ class BugDir (list, settings_object.SavedSettingsObject):
     @_versioned_property(name="user_id",
                          doc=
 """The user's prefered name, e.g. 'John Doe <jdoe@example.com>'.  Note
-that the Arch RCS backend *enforces* ids with this format.""",
+that the Arch VCS backend *enforces* ids with this format.""",
                          change_hook=_set_user_id,
                          generator=_guess_user_id)
     def user_id(): return {}
@@ -192,32 +207,32 @@ that the Arch RCS backend *enforces* ids with this format.""",
 """The default assignee for new bugs e.g. 'John Doe <jdoe@example.com>'.""")
     def default_assignee(): return {}
 
-    @_versioned_property(name="rcs_name",
-                         doc="""The name of the current RCS.  Kept seperate to make saving/loading
-settings easy.  Don't set this attribute.  Set .rcs instead, and
-.rcs_name will be automatically adjusted.""",
+    @_versioned_property(name="vcs_name",
+                         doc="""The name of the current VCS.  Kept seperate to make saving/loading
+settings easy.  Don't set this attribute.  Set .vcs instead, and
+.vcs_name will be automatically adjusted.""",
                          default="None",
                          allowed=["None", "Arch", "bzr", "darcs", "git", "hg"])
-    def rcs_name(): return {}
+    def vcs_name(): return {}
 
-    def _get_rcs(self, rcs_name=None):
+    def _get_vcs(self, vcs_name=None):
         """Get and root a new revision control system"""
-        if rcs_name == None:
-            rcs_name = self.rcs_name
-        new_rcs = rcs.rcs_by_name(rcs_name)
-        self._change_rcs(None, new_rcs)
-        return new_rcs
-    def _change_rcs(self, old_rcs, new_rcs):
-        new_rcs.encoding = self.encoding
-        new_rcs.root(self.root)
-        self.rcs_name = new_rcs.name
+        if vcs_name == None:
+            vcs_name = self.vcs_name
+        new_vcs = vcs.vcs_by_name(vcs_name)
+        self._change_vcs(None, new_vcs)
+        return new_vcs
+    def _change_vcs(self, old_vcs, new_vcs):
+        new_vcs.encoding = self.encoding
+        new_vcs.root(self.root)
+        self.vcs_name = new_vcs.name
 
     @Property
-    @change_hook_property(hook=_change_rcs)
-    @cached_property(generator=_get_rcs)
-    @local_property("rcs")
+    @change_hook_property(hook=_change_vcs)
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
     @doc_property(doc="A revision control system instance.")
-    def rcs(): return {}
+    def vcs(): return {}
 
     def _bug_map_gen(self):
         map = {}
@@ -279,9 +294,8 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
 
 
     def __init__(self, root=None, sink_to_existing_root=True,
-                 assert_new_BugDir=False, allow_rcs_init=False,
-                 manipulate_encodings=True,
-                 from_disk=False, rcs=None):
+                 assert_new_BugDir=False, allow_vcs_init=False,
+                 manipulate_encodings=True, from_disk=False, vcs=None):
         list.__init__(self)
         settings_object.SavedSettingsObject.__init__(self)
         self._manipulate_encodings = manipulate_encodings
@@ -293,9 +307,9 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
             if not os.path.exists(root):
                 raise NoRootEntry(root)
             self.root = root
-        # get a temporary rcs until we've loaded settings
+        # get a temporary vcs until we've loaded settings
         self.sync_with_disk = False
-        self.rcs = self._guess_rcs()
+        self.vcs = self._guess_vcs()
 
         if from_disk == True:
             self.sync_with_disk = True
@@ -305,20 +319,24 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
             if assert_new_BugDir == True:
                 if os.path.exists(self.get_path()):
                     raise AlreadyInitialized, self.get_path()
-            if rcs == None:
-                rcs = self._guess_rcs(allow_rcs_init)
-            self.rcs = rcs
+            if vcs == None:
+                vcs = self._guess_vcs(allow_vcs_init)
+            self.vcs = vcs
             self._setup_user_id(self.user_id)
 
-    def set_sync_with_disk(self, value):
-        self.sync_with_disk = value
-        for bug in self:
-            bug.set_sync_with_disk(value)
+    def __del__(self):
+        self.cleanup()
+
+    def cleanup(self):
+        self.vcs.cleanup()
+
+    # methods for getting the BugDir situated in the filesystem
 
     def _find_root(self, path):
         """
         Search for an existing bug database dir and it's ancestors and
-        return a BugDir rooted there.
+        return a BugDir rooted there.  Only called by __init__, and
+        then only if sink_to_existing_root == True.
         """
         if not os.path.exists(path):
             raise NoRootEntry(path)
@@ -334,136 +352,212 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
                 raise NoBugDir(path)
             return beroot
 
-    def get_version(self, path=None, use_none_rcs=False):
-        if use_none_rcs == True:
-            RCS = rcs.rcs_by_name("None")
-            RCS.root(self.root)
-            RCS.encoding = encoding.get_encoding()
+    def _guess_vcs(self, allow_vcs_init=False):
+        """
+        Only called by __init__.
+        """
+        deepdir = self.get_path()
+        if not os.path.exists(deepdir):
+            deepdir = os.path.dirname(deepdir)
+        new_vcs = vcs.detect_vcs(deepdir)
+        install = False
+        if new_vcs.name == "None":
+            if allow_vcs_init == True:
+                new_vcs = vcs.installed_vcs()
+                new_vcs.init(self.root)
+        return new_vcs
+
+    # methods for saving/loading/accessing settings and properties.
+
+    def get_path(self, *args):
+        """
+        Return a path relative to .root.
+        """
+        dir = os.path.join(self.root, ".be")
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["version", "settings", "bugs"], str(args)
+        return os.path.join(dir, *args)
+
+    def _get_settings(self, settings_path, for_duplicate_bugdir=False):
+        allow_no_vcs = not self.vcs.path_in_root(settings_path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        if self.sync_with_disk == False and for_duplicate_bugdir == False:
+            # duplicates can ignore this bugdir's .sync_with_disk status
+            raise DiskAccessRequired("_get settings")
+        try:
+            settings = mapfile.map_load(self.vcs, settings_path, allow_no_vcs)
+        except vcs.NoSuchFile:
+            settings = {"vcs_name": "None"}
+        return settings
+
+    def _save_settings(self, settings_path, settings,
+                       for_duplicate_bugdir=False):
+        allow_no_vcs = not self.vcs.path_in_root(settings_path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        if self.sync_with_disk == False and for_duplicate_bugdir == False:
+            # duplicates can ignore this bugdir's .sync_with_disk status
+            raise DiskAccessRequired("_save settings")
+        self.vcs.mkdir(self.get_path(), allow_no_vcs)
+        mapfile.map_save(self.vcs, settings_path, settings, allow_no_vcs)
+
+    def load_settings(self):
+        self.settings = self._get_settings(self.get_path("settings"))
+        self._setup_saved_settings()
+        self._setup_user_id(self.user_id)
+        self._setup_encoding(self.encoding)
+        self._setup_severities(self.severities)
+        self._setup_status(self.active_status, self.inactive_status)
+        self.vcs = vcs.vcs_by_name(self.vcs_name)
+        self._setup_user_id(self.user_id)
+
+    def save_settings(self):
+        settings = self._get_saved_settings()
+        self._save_settings(self.get_path("settings"), settings)
+
+    def get_version(self, path=None, use_none_vcs=False,
+                    for_duplicate_bugdir=False):
+        """
+        Requires disk access.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("get version")
+        if use_none_vcs == True:
+            VCS = vcs.vcs_by_name("None")
+            VCS.root(self.root)
+            VCS.encoding = encoding.get_encoding()
         else:
-            RCS = self.rcs
+            VCS = self.vcs
 
         if path == None:
             path = self.get_path("version")
-        tree_version = RCS.get_file_contents(path)
-        return tree_version
+        allow_no_vcs = not VCS.path_in_root(path)
+        if allow_no_vcs == True:
+            assert for_duplicate_bugdir == True
+        version = VCS.get_file_contents(
+            path, allow_no_vcs=allow_no_vcs).rstrip("\n")
+        return version
 
     def set_version(self):
-        self.rcs.mkdir(self.get_path())
-        self.rcs.set_file_contents(self.get_path("version"),
-                                   TREE_VERSION_STRING)
+        """
+        Requires disk access.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("set version")
+        self.vcs.mkdir(self.get_path())
+        self.vcs.set_file_contents(self.get_path("version"),
+                                   upgrade.BUGDIR_DISK_VERSION+"\n")
 
-    def get_path(self, *args):
-        my_dir = os.path.join(self.root, ".be")
-        if len(args) == 0:
-            return my_dir
-        assert args[0] in ["version", "settings", "bugs"], str(args)
-        return os.path.join(my_dir, *args)
+    # methods controlling disk access
 
-    def _guess_rcs(self, allow_rcs_init=False):
-        deepdir = self.get_path()
-        if not os.path.exists(deepdir):
-            deepdir = os.path.dirname(deepdir)
-        new_rcs = rcs.detect_rcs(deepdir)
-        install = False
-        if new_rcs.name == "None":
-            if allow_rcs_init == True:
-                new_rcs = rcs.installed_rcs()
-                new_rcs.init(self.root)
-        return new_rcs
+    def set_sync_with_disk(self, value):
+        """
+        Adjust .sync_with_disk for the BugDir and all it's children.
+        See the BugDir docstring for a description of the role of
+        .sync_with_disk.
+        """
+        self.sync_with_disk = value
+        for bug in self:
+            bug.set_sync_with_disk(value)
 
     def load(self):
-        version = self.get_version(use_none_rcs=True)
-        if version != TREE_VERSION_STRING:
-            raise NotImplementedError, \
-                "BugDir cannot handle version '%s' yet." % version
+        """
+        Reqires disk access
+        """
+        version = self.get_version(use_none_vcs=True)
+        if version != upgrade.BUGDIR_DISK_VERSION:
+            upgrade.upgrade(self.root, version)
         else:
             if not os.path.exists(self.get_path()):
                 raise NoBugDir(self.get_path())
             self.load_settings()
 
-            self.rcs = rcs.rcs_by_name(self.rcs_name)
-            self._setup_user_id(self.user_id)
-
     def load_all_bugs(self):
-        "Warning: this could take a while."
+        """
+        Requires disk access.
+        Warning: this could take a while.
+        """
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load all bugs")
         self._clear_bugs()
         for uuid in self.list_uuids():
             self._load_bug(uuid)
 
     def save(self):
         """
+        Note that this command writes to disk _regardless_ of the
+        status of .sync_with_disk.
+
         Save any loaded contents to disk.  Because of lazy loading of
         bugs and comments, this is actually not too inefficient.
 
-        However, if self.sync_with_disk = True, then any changes are
+        However, if .sync_with_disk = True, then any changes are
         automatically written to disk as soon as they happen, so
         calling this method will just waste time (unless something
         else has been messing with your on-disk files).
+
+        Requires disk access.
         """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
         self.set_version()
         self.save_settings()
         for bug in self:
             bug.save()
+        if sync_with_disk == False:
+            self.set_sync_with_disk(sync_with_disk)
 
-    def load_settings(self):
-        self.settings = self._get_settings(self.get_path("settings"))
-        self._setup_saved_settings()
-        self._setup_user_id(self.user_id)
-        self._setup_encoding(self.encoding)
-        self._setup_severities(self.severities)
-        self._setup_status(self.active_status, self.inactive_status)
+    # methods for managing duplicate BugDirs
 
-    def _get_settings(self, settings_path):
-        allow_no_rcs = not self.rcs.path_in_root(settings_path)
-        # allow_no_rcs=True should only be for the special case of
-        # configuring duplicate bugdir settings
+    def duplicate_bugdir(self, revision):
+        duplicate_path = self.vcs.duplicate_repo(revision)
 
+        duplicate_version_path = os.path.join(duplicate_path, ".be", "version")
         try:
-            settings = mapfile.map_load(self.rcs, settings_path, allow_no_rcs)
-        except rcs.NoSuchFile:
-            settings = {"rcs_name": "None"}
-        return settings
-
-    def save_settings(self):
-        settings = self._get_saved_settings()
-        self._save_settings(self.get_path("settings"), settings)
-
-    def _save_settings(self, settings_path, settings):
-        allow_no_rcs = not self.rcs.path_in_root(settings_path)
-        # allow_no_rcs=True should only be for the special case of
-        # configuring duplicate bugdir settings
-        self.rcs.mkdir(self.get_path(), allow_no_rcs)
-        mapfile.map_save(self.rcs, settings_path, settings, allow_no_rcs)
-
-    def duplicate_bugdir(self, revision):
-        duplicate_path = self.rcs.duplicate_repo(revision)
+            version = self.get_version(duplicate_version_path,
+                                       for_duplicate_bugdir=True)
+        except DiskAccessRequired:
+            self.sync_with_disk = True # temporarily allow access
+            version = self.get_version(duplicate_version_path,
+                                       for_duplicate_bugdir=True)
+            self.sync_with_disk = False
+        if version != upgrade.BUGDIR_DISK_VERSION:
+            upgrade.upgrade(duplicate_path, version)
 
-        # setup revision RCS as None, since the duplicate may not be
+        # setup revision VCS as None, since the duplicate may not be
         # initialized for versioning
         duplicate_settings_path = os.path.join(duplicate_path,
                                                ".be", "settings")
-        duplicate_settings = self._get_settings(duplicate_settings_path)
-        if "rcs_name" in duplicate_settings:
-            duplicate_settings["rcs_name"] = "None"
+        duplicate_settings = self._get_settings(duplicate_settings_path,
+                                                for_duplicate_bugdir=True)
+        if "vcs_name" in duplicate_settings:
+            duplicate_settings["vcs_name"] = "None"
             duplicate_settings["user_id"] = self.user_id
         if "disabled" in bug.status_values:
             # Hack to support old versions of BE bugs
             duplicate_settings["inactive_status"] = self.inactive_status
-        self._save_settings(duplicate_settings_path, duplicate_settings)
+        self._save_settings(duplicate_settings_path, duplicate_settings,
+                            for_duplicate_bugdir=True)
 
         return BugDir(duplicate_path, from_disk=True, manipulate_encodings=self._manipulate_encodings)
 
     def remove_duplicate_bugdir(self):
-        self.rcs.remove_duplicate_repo()
+        self.vcs.remove_duplicate_repo()
+
+    # methods for managing bugs
 
     def list_uuids(self):
         uuids = []
-        if os.path.exists(self.get_path()):
+        if self.sync_with_disk == True and os.path.exists(self.get_path()):
             # list the uuids on disk
-            for uuid in os.listdir(self.get_path("bugs")):
-                if not (uuid.startswith('.')):
-                    uuids.append(uuid)
-                    yield uuid
+            if os.path.exists(self.get_path("bugs")):
+                for uuid in os.listdir(self.get_path("bugs")):
+                    if not (uuid.startswith('.')):
+                        uuids.append(uuid)
+                        yield uuid
         # and the ones that are still just in memory
         for bug in self:
             if bug.uuid not in uuids:
@@ -476,6 +570,8 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
         self._bug_map_gen()
 
     def _load_bug(self, uuid):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("_load bug")
         bg = bug.Bug(bugdir=self, uuid=uuid, from_disk=True)
         self.append(bg)
         self._bug_map_gen()
@@ -492,7 +588,8 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
 
     def remove_bug(self, bug):
         self.remove(bug)
-        bug.remove()
+        if bug.sync_with_disk == True:
+            bug.remove()
 
     def bug_shortname(self, bug):
         """
@@ -514,12 +611,13 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
 
     def bug_from_shortname(self, shortname):
         """
-        >>> bd = simple_bug_dir()
+        >>> bd = SimpleBugDir(sync_with_disk=False)
         >>> bug_a = bd.bug_from_shortname('a')
         >>> print type(bug_a)
         <class 'libbe.bug.Bug'>
         >>> print bug_a
         a:om: Bug A
+        >>> bd.cleanup()
         """
         matches = []
         self._bug_map_gen()
@@ -530,7 +628,7 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
             raise MultipleBugMatches(shortname, matches)
         if len(matches) == 1:
             return self.bug_from_uuid(matches[0])
-        raise KeyError("No bug matches %s" % shortname)
+        raise NoBugMatches(shortname)
 
     def bug_from_uuid(self, uuid):
         if not self.has_bug(uuid):
@@ -548,41 +646,56 @@ settings easy.  Don't set this attribute.  Set .rcs instead, and
         return True
 
 
-def simple_bug_dir():
+class SimpleBugDir (BugDir):
     """
-    For testing
-    >>> bugdir = simple_bug_dir()
-    >>> ls = list(bugdir.list_uuids())
-    >>> ls.sort()
-    >>> print ls
+    For testing.  Set sync_with_disk==False for a memory-only bugdir.
+    >>> bugdir = SimpleBugDir()
+    >>> uuids = list(bugdir.list_uuids())
+    >>> uuids.sort()
+    >>> print uuids
     ['a', 'b']
+    >>> bugdir.cleanup()
     """
-    dir = utility.Dir()
-    assert os.path.exists(dir.path)
-    bugdir = BugDir(dir.path, sink_to_existing_root=False, allow_rcs_init=True,
+    def __init__(self, sync_with_disk=True):
+        if sync_with_disk == True:
+            dir = utility.Dir()
+            assert os.path.exists(dir.path)
+            root = dir.path
+            assert_new_BugDir = True
+            vcs_init = True
+        else:
+            root = "/"
+            assert_new_BugDir = False
+            vcs_init = False
+        BugDir.__init__(self, root, sink_to_existing_root=False,
+                    assert_new_BugDir=assert_new_BugDir,
+                    allow_vcs_init=vcs_init,
                     manipulate_encodings=False)
-    bugdir._dir_ref = dir # postpone cleanup since dir.__del__() removes dir.
-    bug_a = bugdir.new_bug("a", summary="Bug A")
-    bug_a.creator = "John Doe <jdoe@example.com>"
-    bug_a.time = 0
-    bug_b = bugdir.new_bug("b", summary="Bug B")
-    bug_b.creator = "Jane Doe <jdoe@example.com>"
-    bug_b.time = 0
-    bug_b.status = "closed"
-    bugdir.save()
-    return bugdir
-
+        if sync_with_disk == True: # postpone cleanup since dir.__del__() removes dir.
+            self._dir_ref = dir
+        bug_a = self.new_bug("a", summary="Bug A")
+        bug_a.creator = "John Doe <jdoe@example.com>"
+        bug_a.time = 0
+        bug_b = self.new_bug("b", summary="Bug B")
+        bug_b.creator = "Jane Doe <jdoe@example.com>"
+        bug_b.time = 0
+        bug_b.status = "closed"
+        if sync_with_disk == True:
+            self.save()
+            self.set_sync_with_disk(True)
+    def cleanup(self):
+        if hasattr(self, "_dir_ref"):
+            self._dir_ref.cleanup()
+        BugDir.cleanup(self)
 
 class BugDirTestCase(unittest.TestCase):
-    def __init__(self, *args, **kwargs):
-        unittest.TestCase.__init__(self, *args, **kwargs)
     def setUp(self):
         self.dir = utility.Dir()
         self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
-                             allow_rcs_init=True)
-        self.rcs = self.bugdir.rcs
+                             allow_vcs_init=True)
+        self.vcs = self.bugdir.vcs
     def tearDown(self):
-        self.rcs.cleanup()
+        self.bugdir.cleanup()
         self.dir.cleanup()
     def fullPath(self, path):
         return os.path.join(self.dir.path, path)
@@ -593,13 +706,13 @@ class BugDirTestCase(unittest.TestCase):
         self.assertRaises(AlreadyInitialized, BugDir,
                           self.dir.path, assertNewBugDir=True)
     def versionTest(self):
-        if self.rcs.versioned == False:
+        if self.vcs.versioned == False:
             return
-        original = self.bugdir.rcs.commit("Began versioning")
+        original = self.bugdir.vcs.commit("Began versioning")
         bugA = self.bugdir.bug_from_uuid("a")
         bugA.status = "fixed"
         self.bugdir.save()
-        new = self.rcs.commit("Fixed bug a")
+        new = self.vcs.commit("Fixed bug a")
         dupdir = self.bugdir.duplicate_bugdir(original)
         self.failUnless(dupdir.root != self.bugdir.root,
                         "%s, %s" % (dupdir.root, self.bugdir.root))
@@ -645,17 +758,19 @@ class BugDirTestCase(unittest.TestCase):
         rep.new_reply("And they have six legs.")
         if sync_with_disk == False:
             self.bugdir.save()
+            self.bugdir.set_sync_with_disk(True)
         self.bugdir._clear_bugs()
         bug = self.bugdir.bug_from_uuid("a")
         bug.load_comments()
+        if sync_with_disk == False:
+            self.bugdir.set_sync_with_disk(False)
         self.failUnless(len(bug.comment_root)==1, len(bug.comment_root))
         for index,comment in enumerate(bug.comments()):
             if index == 0:
                 repLoaded = comment
                 self.failUnless(repLoaded.uuid == rep.uuid, repLoaded.uuid)
-                self.failUnless(comment.sync_with_disk == True,
+                self.failUnless(comment.sync_with_disk == sync_with_disk,
                                 comment.sync_with_disk)
-                #load_settings()
                 self.failUnless(comment.content_type == "text/plain",
                                 comment.content_type)
                 self.failUnless(repLoaded.settings["Content-type"]=="text/plain",
@@ -672,5 +787,46 @@ class BugDirTestCase(unittest.TestCase):
     def testSyncedComments(self):
         self.testComments(sync_with_disk=True)
 
-unitsuite = unittest.TestLoader().loadTestsFromTestCase(BugDirTestCase)
-suite = unittest.TestSuite([unitsuite])#, doctest.DocTestSuite()])
+class SimpleBugDirTestCase (unittest.TestCase):
+    def setUp(self):
+        # create a pre-existing bugdir in a temporary directory
+        self.dir = utility.Dir()
+        self.original_working_dir = os.getcwd()
+        os.chdir(self.dir.path)
+        self.bugdir = BugDir(self.dir.path, sink_to_existing_root=False,
+                             allow_vcs_init=True)
+        self.bugdir.new_bug("preexisting", summary="Hopefully not imported")
+        self.bugdir.save()
+    def tearDown(self):
+        os.chdir(self.original_working_dir)
+        self.bugdir.cleanup()
+        self.dir.cleanup()
+    def testOnDiskCleanLoad(self):
+        """SimpleBugDir(sync_with_disk==True) should not import preexisting bugs."""
+        bugdir = SimpleBugDir(sync_with_disk=True)
+        self.failUnless(bugdir.sync_with_disk==True, bugdir.sync_with_disk)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir._clear_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == [], uuids)
+        bugdir.load_all_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir.cleanup()
+    def testInMemoryCleanLoad(self):
+        """SimpleBugDir(sync_with_disk==False) should not import preexisting bugs."""
+        bugdir = SimpleBugDir(sync_with_disk=False)
+        self.failUnless(bugdir.sync_with_disk==False, bugdir.sync_with_disk)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        self.failUnlessRaises(DiskAccessRequired, bugdir.load_all_bugs)
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == ['a', 'b'], uuids)
+        bugdir._clear_bugs()
+        uuids = sorted([bug.uuid for bug in bugdir])
+        self.failUnless(uuids == [], uuids)
+        bugdir.cleanup()
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index d7cd1e5693b1b6248d4885aa615a6ae947262de9..e9e0649615ef86a0c618228fa091f3f42c1f2221 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Bazaar (bzr) backend.
+"""
+
 import os
 import re
 import sys
 import unittest
 import doctest
 
-import rcs
-from rcs import RCS
+import vcs
+
 
 def new():
     return Bzr()
 
-class Bzr(RCS):
+class Bzr(vcs.VCS):
     name = "bzr"
     client = "bzr"
     versioned = True
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output        
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         if self._u_search_parent_directories(path, ".bzr") != None :
             return True
         return False
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         """Find the root of the deepest repository containing path."""
         status,output,error = self._u_invoke_client("root", path)
         return output.rstrip('\n')
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._u_invoke_client("init", directory=path)
-    def _rcs_get_user_id(self):
+    def _vcs_get_user_id(self):
         status,output,error = self._u_invoke_client("whoami")
         return output.rstrip('\n')
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         self._u_invoke_client("whoami", value)
-    def _rcs_add(self, path):
+    def _vcs_add(self, path):
         self._u_invoke_client("add", path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         # --force to also remove unversioned files.
         self._u_invoke_client("remove", "--force", path)
-    def _rcs_update(self, path):
+    def _vcs_update(self, path):
         pass
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision, binary=binary)
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
         else:
             status,output,error = \
                 self._u_invoke_client("cat","-r",revision,path)
             return output
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision == None:
-            RCS._rcs_duplicate_repo(self, directory, revision)
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             self._u_invoke_client("branch", "--revision", revision,
                                   ".", directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         args = ["commit", "--file", commitfile]
         if allow_empty == True:
             args.append("--unchanged")
@@ -83,9 +87,9 @@ class Bzr(RCS):
                 strings = ["ERROR: no changes to commit.", # bzr 1.3.1
                            "ERROR: No changes to commit."] # bzr 1.15.1
                 if self._u_any_in_string(strings, error) == True:
-                    raise rcs.EmptyCommit()
+                    raise vcs.EmptyCommit()
                 else:
-                    raise rcs.CommandError(args, status, error)
+                    raise vcs.CommandError(args, status, stdout="", stderr=error)
         revision = None
         revline = re.compile("Committed revision (.*)[.]")
         match = revline.search(error)
@@ -93,9 +97,17 @@ class Bzr(RCS):
         assert len(match.groups()) == 1
         revision = match.groups()[0]
         return revision
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("revno")
+        current_revision = int(output)
+        if index >= current_revision or index < -current_revision:
+            return None
+        if index >= 0:
+            return str(index+1) # bzr commit 0 is the empty tree.
+        return str(current_revision+index+1)
 
 \f    
-rcs.make_rcs_testcase_subclasses(Bzr, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Bzr, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index 853a75a406f53fa863a9263ad5ff12566f5df41a..9b6414259750565523f98a6bfa5f5ae0b380a28a 100644 (file)
 # 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.
+
+"""
+Define assorted utilities to make command-line handling easier.
+"""
+
 import glob
 import optparse
 import os
@@ -70,10 +75,11 @@ def get_command(command_name):
     return cmd
 
 
-def execute(cmd, args):
+def execute(cmd, args, manipulate_encodings=True):
     enc = encoding.get_encoding()
     cmd = get_command(cmd)
-    ret = cmd.execute([a.decode(enc) for a in args])
+    ret = cmd.execute([a.decode(enc) for a in args],
+                      manipulate_encodings=manipulate_encodings)
     if ret == None:
         ret = 0
     return ret
@@ -206,6 +212,15 @@ def underlined(instring):
     
     return "%s\n%s" % (instring, "="*len(instring))
 
+def bug_from_shortname(bdir, shortname):
+    """
+    Exception translation for the command-line interface.
+    """
+    try:
+        bug = bdir.bug_from_shortname(shortname)
+    except (bugdir.MultipleBugMatches, bugdir.NoBugMatches), e:
+        raise UserError(e.message)
+    return bug
 
 def _test():
     import doctest
index 3249e8bdc6ba1e63592ebed2f41b91185b7145a2..41bc7e6c1ea629277e8836186ad570ab676ea22a 100644 (file)
 # 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.
+
+"""
+Define the Comment class for representing bug comments.
+"""
+
 import base64
 import os
 import os.path
@@ -61,6 +66,11 @@ class MissingReference(ValueError):
         self.reference = comment.in_reply_to
         self.comment = comment
 
+class DiskAccessRequired (Exception):
+    def __init__(self, goal):
+        msg = "Cannot %s without accessing the disk" % goal
+        Exception.__init__(self, msg)
+
 INVALID_UUID = "!!~~\n INVALID-UUID \n~~!!"
 
 def list_to_root(comments, bug, root=None,
@@ -115,8 +125,10 @@ def loadComments(bug, load_full=False):
     Set load_full=True when you want to load the comment completely
     from disk *now*, rather than waiting and lazy loading as required.
     """
+    if bug.sync_with_disk == False:
+        raise DiskAccessRequired("load comments")
     path = bug.get_path("comments")
-    if not os.path.isdir(path):
+    if not os.path.exists(path):
         return Comment(bug, uuid=INVALID_UUID)
     comments = []
     for uuid in os.listdir(path):
@@ -131,6 +143,8 @@ def loadComments(bug, load_full=False):
     return list_to_root(comments, bug)
 
 def saveComments(bug):
+    if bug.sync_with_disk == False:
+        raise DiskAccessRequired("save comments")
     for comment in bug.comment_root.traverse():
         comment.save()
 
@@ -162,9 +176,9 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                          doc="Alternate ID for linking imported comments.  Internally comments are linked (via In-reply-to) to the parent's UUID.  However, these UUIDs are generated internally, so Alt-id is provided as a user-controlled linking target.")
     def alt_id(): return {}
 
-    @_versioned_property(name="From",
+    @_versioned_property(name="Author",
                          doc="The author of the comment")
-    def From(): return {}
+    def author(): return {}
 
     @_versioned_property(name="In-reply-to",
                          doc="UUID for parent comment or bug")
@@ -178,28 +192,28 @@ class Comment(Tree, settings_object.SavedSettingsObject):
 
     @_versioned_property(name="Date",
                          doc="An RFC 2822 timestamp for comment creation")
-    def time_string(): return {}
+    def date(): return {}
 
     def _get_time(self):
-        if self.time_string == None:
+        if self.date == None:
             return None
-        return utility.str_to_time(self.time_string)
+        return utility.str_to_time(self.date)
     def _set_time(self, value):
-        self.time_string = utility.time_to_str(value)
+        self.date = utility.time_to_str(value)
     time = property(fget=_get_time,
                     fset=_set_time,
-                    doc="An integer version of .time_string")
+                    doc="An integer version of .date")
 
     def _get_comment_body(self):
-        if self.rcs != None and self.sync_with_disk == True:
-            import rcs
+        if self.vcs != None and self.sync_with_disk == True:
+            import vcs
             binary = not self.content_type.startswith("text/")
-            return self.rcs.get_file_contents(self.get_path("body"), binary=binary)
+            return self.vcs.get_file_contents(self.get_path("body"), binary=binary)
     def _set_comment_body(self, old=None, new=None, force=False):
-        if (self.rcs != None and self.sync_with_disk == True) or force==True:
+        if (self.vcs != None and self.sync_with_disk == True) or force==True:
             assert new != None, "Can't save empty comment"
             binary = not self.content_type.startswith("text/")
-            self.rcs.set_file_contents(self.get_path("body"), new, binary=binary)
+            self.vcs.set_file_contents(self.get_path("body"), new, binary=binary)
 
     @Property
     @change_hook_property(hook=_set_comment_body)
@@ -208,15 +222,15 @@ class Comment(Tree, settings_object.SavedSettingsObject):
     @doc_property(doc="The meat of the comment")
     def body(): return {}
 
-    def _get_rcs(self):
-        if hasattr(self.bug, "rcs"):
-            return self.bug.rcs
+    def _get_vcs(self):
+        if hasattr(self.bug, "vcs"):
+            return self.bug.vcs
 
     @Property
-    @cached_property(generator=_get_rcs)
-    @local_property("rcs")
+    @cached_property(generator=_get_vcs)
+    @local_property("vcs")
     @doc_property(doc="A revision control system instance.")
-    def rcs(): return {}
+    def vcs(): return {}
 
     def _extra_strings_check_fn(value):
         return utility.iterable_full_of_strings(value, \
@@ -257,13 +271,29 @@ class Comment(Tree, settings_object.SavedSettingsObject):
             if uuid == None:
                 self.uuid = uuid_gen()
             self.time = int(time.time()) # only save to second precision
-            if self.rcs != None:
-                self.From = self.rcs.get_user_id()
+            if self.vcs != None:
+                self.author = self.vcs.get_user_id()
             self.in_reply_to = in_reply_to
             self.body = body
 
-    def set_sync_with_disk(self, value):
-        self.sync_with_disk = True
+    def __cmp__(self, other):
+        return cmp_full(self, other)
+
+    def __str__(self):
+        """
+        >>> comm = Comment(bug=None, body="Some insightful remarks")
+        >>> comm.uuid = "com-1"
+        >>> comm.date = "Thu, 20 Nov 2008 15:55:11 +0000"
+        >>> comm.author = "Jane Doe <jdoe@example.com>"
+        >>> print comm
+        --------- Comment ---------
+        Name: com-1
+        From: Jane Doe <jdoe@example.com>
+        Date: Thu, 20 Nov 2008 15:55:11 +0000
+        <BLANKLINE>
+        Some insightful remarks
+        """
+        return self.string()
 
     def traverse(self, *args, **kwargs):
         """Avoid working with the possible dummy root comment"""
@@ -272,6 +302,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 continue
             yield comment
 
+    # serializing methods
+
     def _setting_attr_string(self, setting):
         value = getattr(self, setting)
         if value == None:
@@ -282,12 +314,12 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         """
         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
         >>> comm.uuid = "0123"
-        >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> print comm.xml(indent=2, shortname="com-1")
           <comment>
             <uuid>0123</uuid>
             <short-name>com-1</short-name>
-            <from></from>
+            <author></author>
             <date>Thu, 01 Jan 1970 00:00:00 +0000</date>
             <content-type>text/plain</content-type>
             <body>Some
@@ -309,8 +341,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 ("alt-id", self.alt_id),
                 ("short-name", shortname),
                 ("in-reply-to", self.in_reply_to),
-                ("from", self._setting_attr_string("From")),
-                ("date", self.time_string),
+                ("author", self._setting_attr_string("author")),
+                ("date", self.date),
                 ("content-type", self.content_type),
                 ("body", body)]
         lines = ["<comment>"]
@@ -328,11 +360,11 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         <alt-id> fields.
         >>> commA = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
         >>> commA.uuid = "0123"
-        >>> commA.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> commA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> xml = commA.xml(shortname="com-1")
         >>> commB = Comment()
         >>> commB.from_xml(xml)
-        >>> attrs=['uuid','alt_id','in_reply_to','From','time_string','content_type','body']
+        >>> attrs=['uuid','alt_id','in_reply_to','author','date','content_type','body']
         >>> for attr in attrs: # doctest: +ELLIPSIS
         ...     if getattr(commB, attr) != getattr(commA, attr):
         ...         estr = "Mismatch on %s: '%s' should be '%s'"
@@ -342,15 +374,15 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         Mismatch on alt_id: '0123' should be 'None'
         >>> print commB.alt_id
         0123
-        >>> commA.From
-        >>> commB.From
+        >>> commA.author
+        >>> commB.author
         """
         if type(xml_string) == types.UnicodeType:
             xml_string = xml_string.strip().encode("unicode_escape")
         comment = ElementTree.XML(xml_string)
         if comment.tag != "comment":
             raise InvalidXML(comment, "root element must be <comment>")
-        tags=['uuid','alt-id','in-reply-to','from','date','content-type','body']
+        tags=['uuid','alt-id','in-reply-to','author','date','content-type','body']
         uuid = None
         body = None
         for child in comment.getchildren():
@@ -368,10 +400,6 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 if child.tag == "body":
                     body = text
                     continue # don't set the bug's body yet.
-                elif child.tag == 'from':
-                    attr_name = "From"
-                elif child.tag == 'date':
-                    attr_name = 'time_string'
                 else:
                     attr_name = child.tag.replace('-','_')
                 setattr(self, attr_name, text)
@@ -389,7 +417,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
     def string(self, indent=0, shortname=None):
         """
         >>> comm = Comment(bug=None, body="Some\\ninsightful\\nremarks\\n")
-        >>> comm.time_string = "Thu, 01 Jan 1970 00:00:00 +0000"
+        >>> comm.date = "Thu, 01 Jan 1970 00:00:00 +0000"
         >>> print comm.string(indent=2, shortname="com-1")
           --------- Comment ---------
           Name: com-1
@@ -405,8 +433,8 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         lines = []
         lines.append("--------- Comment ---------")
         lines.append("Name: %s" % shortname)
-        lines.append("From: %s" % (self._setting_attr_string("From")))
-        lines.append("Date: %s" % self.time_string)
+        lines.append("From: %s" % (self._setting_attr_string("author")))
+        lines.append("Date: %s" % self.date)
         lines.append("")
         if self.content_type.startswith("text/"):
             lines.extend((self.body or "").splitlines())
@@ -417,78 +445,6 @@ class Comment(Tree, settings_object.SavedSettingsObject):
         sep = '\n' + istring
         return istring + sep.join(lines).rstrip('\n')
 
-    def __str__(self):
-        """
-        >>> comm = Comment(bug=None, body="Some insightful remarks")
-        >>> comm.uuid = "com-1"
-        >>> comm.time_string = "Thu, 20 Nov 2008 15:55:11 +0000"
-        >>> comm.From = "Jane Doe <jdoe@example.com>"
-        >>> print comm
-        --------- Comment ---------
-        Name: com-1
-        From: Jane Doe <jdoe@example.com>
-        Date: Thu, 20 Nov 2008 15:55:11 +0000
-        <BLANKLINE>
-        Some insightful remarks
-        """
-        return self.string()
-
-    def get_path(self, name=None):
-        my_dir = os.path.join(self.bug.get_path("comments"), self.uuid)
-        if name is None:
-            return my_dir
-        assert name in ["values", "body"]
-        return os.path.join(my_dir, name)
-
-    def load_settings(self):
-        self.settings = mapfile.map_load(self.rcs, self.get_path("values"))
-        self._setup_saved_settings()
-
-    def save_settings(self):
-        self.rcs.mkdir(self.get_path())
-        path = self.get_path("values")
-        mapfile.map_save(self.rcs, path, self._get_saved_settings())
-
-    def save(self):
-        """
-        Save any loaded contents to disk.
-        
-        However, if self.sync_with_disk = True, then any changes are
-        automatically written to disk as soon as they happen, so
-        calling this method will just waste time (unless something
-        else has been messing with your on-disk files).
-        """
-        assert self.body != None, "Can't save blank comment"
-        self.save_settings()
-        self._set_comment_body(new=self.body, force=True)
-
-    def remove(self):
-        for comment in self.traverse():
-            path = comment.get_path()
-            self.rcs.recursive_remove(path)
-
-    def add_reply(self, reply, allow_time_inversion=False):
-        if self.uuid != INVALID_UUID:
-            reply.in_reply_to = self.uuid
-        self.append(reply)
-        #raise Exception, "adding reply \n%s\n%s" % (self, reply)
-
-    def new_reply(self, body=None):
-        """
-        >>> comm = Comment(bug=None, body="Some insightful remarks")
-        >>> repA = comm.new_reply("Critique original comment")
-        >>> repB = repA.new_reply("Begin flamewar :p")
-        >>> repB.in_reply_to == repA.uuid
-        True
-        """
-        reply = Comment(self.bug, body=body)
-        if self.bug != None:
-            reply.set_sync_with_disk(self.bug.sync_with_disk)
-        if reply.sync_with_disk == True:
-            reply.save()
-        self.add_reply(reply)
-        return reply
-
     def string_thread(self, string_method_name="string", name_map={},
                       indent=0, flatten=True,
                       auto_name_map=False, bug_shortname=None):
@@ -506,7 +462,7 @@ class Comment(Tree, settings_object.SavedSettingsObject):
           name_map = {}
           for shortname,comment in comm.comment_shortnames(bug_shortname):
               name_map[comment.uuid] = shortname
-          comm.sort(key=lambda c : c.From) # your sort
+          comm.sort(key=lambda c : c.author) # your sort
           comm.string_thread(name_map=name_map)
 
         >>> a = Comment(bug=None, uuid="a", body="Insightful remarks")
@@ -593,6 +549,77 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                                   indent=indent, auto_name_map=auto_name_map,
                                   bug_shortname=bug_shortname)
 
+    # methods for saving/loading/acessing settings and properties.
+
+    def get_path(self, *args):
+        dir = os.path.join(self.bug.get_path("comments"), self.uuid)
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["values", "body"], str(args)
+        return os.path.join(dir, *args)
+
+    def set_sync_with_disk(self, value):
+        self.sync_with_disk = value
+
+    def load_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("load settings")
+        self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
+        self._setup_saved_settings()
+
+    def save_settings(self):
+        if self.sync_with_disk == False:
+            raise DiskAccessRequired("save settings")
+        self.vcs.mkdir(self.get_path())
+        path = self.get_path("values")
+        mapfile.map_save(self.vcs, path, self._get_saved_settings())
+
+    def save(self):
+        """
+        Save any loaded contents to disk.
+        
+        However, if self.sync_with_disk = True, then any changes are
+        automatically written to disk as soon as they happen, so
+        calling this method will just waste time (unless something
+        else has been messing with your on-disk files).
+        """
+        sync_with_disk = self.sync_with_disk
+        if sync_with_disk == False:
+            self.set_sync_with_disk(True)
+        assert self.body != None, "Can't save blank comment"
+        self.save_settings()
+        self._set_comment_body(new=self.body, force=True)
+        if sync_with_disk == False:
+            self.set_sync_with_disk(False)
+
+    def remove(self):
+        if self.sync_with_disk == False and self.uuid != INVALID_UUID:
+            raise DiskAccessRequired("remove")
+        for comment in self.traverse():
+            path = comment.get_path()
+            self.vcs.recursive_remove(path)
+
+    def add_reply(self, reply, allow_time_inversion=False):
+        if self.uuid != INVALID_UUID:
+            reply.in_reply_to = self.uuid
+        self.append(reply)
+
+    def new_reply(self, body=None):
+        """
+        >>> comm = Comment(bug=None, body="Some insightful remarks")
+        >>> repA = comm.new_reply("Critique original comment")
+        >>> repB = repA.new_reply("Begin flamewar :p")
+        >>> repB.in_reply_to == repA.uuid
+        True
+        """
+        reply = Comment(self.bug, body=body)
+        if self.bug != None:
+            reply.set_sync_with_disk(self.bug.sync_with_disk)
+        if reply.sync_with_disk == True:
+            reply.save()
+        self.add_reply(reply)
+        return reply
+
     def comment_shortnames(self, bug_shortname=None):
         """
         Iterate through (id, comment) pairs, in time order.
@@ -659,4 +686,59 @@ class Comment(Tree, settings_object.SavedSettingsObject):
                 return comment
         raise KeyError(uuid)
 
+def cmp_attr(comment_1, comment_2, attr, invert=False):
+    """
+    Compare a general attribute between two comments using the conventional
+    comparison rule for that attribute type.  If invert == True, sort
+    *against* that convention.
+    >>> attr="author"
+    >>> commentA = Comment()
+    >>> commentB = Comment()
+    >>> commentA.author = "John Doe"
+    >>> commentB.author = "Jane Doe"
+    >>> cmp_attr(commentA, commentB, attr) > 0
+    True
+    >>> cmp_attr(commentA, commentB, attr, invert=True) < 0
+    True
+    >>> commentB.author = "John Doe"
+    >>> cmp_attr(commentA, commentB, attr) == 0
+    True
+    """
+    if not hasattr(comment_2, attr) :
+        return 1
+    val_1 = getattr(comment_1, attr)
+    val_2 = getattr(comment_2, attr)
+    if val_1 == None: val_1 = None
+    if val_2 == None: val_2 = None
+    
+    if invert == True :
+        return -cmp(val_1, val_2)
+    else :
+        return cmp(val_1, val_2)
+
+# alphabetical rankings (a < z)
+cmp_uuid = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "uuid")
+cmp_author = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "author")
+cmp_in_reply_to = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "in_reply_to")
+cmp_content_type = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "content_type")
+cmp_body = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "body")
+# chronological rankings (newer < older)
+cmp_time = lambda comment_1, comment_2 : cmp_attr(comment_1, comment_2, "time", invert=True)
+
+DEFAULT_CMP_FULL_CMP_LIST = \
+    (cmp_time, cmp_author, cmp_content_type, cmp_body, cmp_in_reply_to,
+     cmp_uuid)
+
+class CommentCompoundComparator (object):
+    def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
+        self.cmp_list = cmp_list
+    def __call__(self, comment_1, comment_2):
+        for comparison in self.cmp_list :
+            val = comparison(comment_1, comment_2)
+            if val != 0 :
+                return val
+        return 0
+        
+cmp_full = CommentCompoundComparator()
+
 suite = doctest.DocTestSuite()
index 5e343b939ca2d94f9bf4c7d01f7f94e498875d1d..fb5a0288fbd0b8a3faf491197e6e2000fd4ae662 100644 (file)
 # 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.
+
+"""
+Create, save, and load the per-user config file at path().
+"""
+
 import ConfigParser
 import codecs
 import locale
@@ -21,6 +26,7 @@ import os.path
 import sys
 import doctest
 
+
 default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
 
 def path():
index e7132c017cafe2834d11ca3925e8a0b6c4e34eb3..16005f2a71e065f2fbb8155920bf0fabea6632a9 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Darcs backend.
+"""
+
 import codecs
 import os
 import re
 import sys
-import unittest
+try: # import core module, Python >= 2.5
+    from xml.etree import ElementTree
+except ImportError: # look for non-core module
+    from elementtree import ElementTree
+from xml.sax.saxutils import unescape
 import doctest
+import unittest
+
+import vcs
 
-import rcs
-from rcs import RCS
 
 def new():
     return Darcs()
 
-class Darcs(RCS):
+class Darcs(vcs.VCS):
     name="darcs"
     client="darcs"
     versioned=True
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         if self._u_search_parent_directories(path, "_darcs") != None :
             return True
         return False 
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         """Find the root of the deepest repository containing path."""
         # Assume that nothing funny is going on; in particular, that we aren't
         # dealing with a bare repo.
@@ -48,9 +57,9 @@ class Darcs(RCS):
         if darcs_dir == None:
             return None
         return os.path.dirname(darcs_dir)
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._u_invoke_client("init", directory=path)
-    def _rcs_get_user_id(self):
+    def _vcs_get_user_id(self):
         # following http://darcs.net/manual/node4.html#SECTION00410030000000000000
         # as of June 29th, 2009
         if self.rootdir == None:
@@ -65,32 +74,32 @@ class Darcs(RCS):
             if env_variable in os.environ:
                 return os.environ[env_variable]
         return None
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         if self.rootdir == None:
             self.root(".")
             if self.rootdir == None:
-                raise rcs.SettingIDnotSupported
+                raise vcs.SettingIDnotSupported
         author_path = os.path.join(self.rootdir, "_darcs", "prefs", "author")
         f = codecs.open(author_path, "w", self.encoding)
         f.write(value)
         f.close()
-    def _rcs_add(self, path):
+    def _vcs_add(self, path):
         if os.path.isdir(path):
             return
         self._u_invoke_client("add", path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         if not os.path.isdir(self._u_abspath(path)):
             os.remove(os.path.join(self.rootdir, path)) # darcs notices removal
-    def _rcs_update(self, path):
+    def _vcs_update(self, path):
         pass # darcs notices changes
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision,
+            return vcs.VCS._vcs_get_file_contents(self, path, revision,
                                               binary=binary)
         else:
             try:
                 return self._u_invoke_client("show", "contents", "--patch", revision, path)
-            except rcs.CommandError:
+            except vcs.CommandError:
                 # Darcs versions < 2.0.0pre2 lack the "show contents" command
 
                 status,output,error = self._u_invoke_client("diff", "--unified",
@@ -113,7 +122,7 @@ class Darcs(RCS):
                 status,output,error = self._u_invoke(args, stdin=target_patch)
 
                 if os.path.exists(os.path.join(self.rootdir, path)) == True:
-                    contents = RCS._rcs_get_file_contents(self, path,
+                    contents = vcs.VCS._vcs_get_file_contents(self, path,
                                                           binary=binary)
                 else:
                     contents = ""
@@ -123,41 +132,53 @@ class Darcs(RCS):
                 status,output,error = self._u_invoke(args, stdin=target_patch)
                 args=["patch", path]
                 status,output,error = self._u_invoke(args, stdin=major_patch)
-                current_contents = RCS._rcs_get_file_contents(self, path,
+                current_contents = vcs.VCS._vcs_get_file_contents(self, path,
                                                               binary=binary)
                 return contents
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision==None:
-            RCS._rcs_duplicate_repo(self, directory, revision)
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             self._u_invoke_client("put", "--to-patch", revision, directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         id = self.get_user_id()
         if '@' not in id:
             id = "%s <%s@invalid.com>" % (id, id)
         args = ['record', '--all', '--author', id, '--logfile', commitfile]
         status,output,error = self._u_invoke_client(*args)
         empty_strings = ["No changes!"]
-        revision = None
         if self._u_any_in_string(empty_strings, output) == True:
             if allow_empty == False:
-                raise rcs.EmptyCommit()
-            else: # we need a extra call to get the current revision
-                args = ["changes", "--last=1", "--xml"]
-                status,output,error = self._u_invoke_client(*args)
-                revline = re.compile("[ \t]*<name>(.*)</name>")
-                # note that darcs does _not_ make an empty revision.
-                # this returns the last non-empty revision id...
+                raise vcs.EmptyCommit()
+            # note that darcs does _not_ make an empty revision.
+            # this returns the last non-empty revision id...
+            revision = self._vcs_revision_id(-1)
         else:
             revline = re.compile("Finished recording patch '(.*)'")
-        match = revline.search(output)
-        assert match != None, output+error
-        assert len(match.groups()) == 1
-        revision = match.groups()[0]
+            match = revline.search(output)
+            assert match != None, output+error
+            assert len(match.groups()) == 1
+            revision = match.groups()[0]
         return revision
-
+    def _vcs_revision_id(self, index):
+        status,output,error = self._u_invoke_client("changes", "--xml")
+        revisions = []
+        xml_str = output.encode("unicode_escape").replace(r"\n", "\n")
+        element = ElementTree.XML(xml_str)
+        assert element.tag == "changelog", element.tag
+        for patch in element.getchildren():
+            assert patch.tag == "patch", patch.tag
+            for child in patch.getchildren():
+                if child.tag == "name":
+                    text = unescape(unicode(child.text).decode("unicode_escape").strip())
+                    revisions.append(text)
+        revisions.reverse()
+        try:
+            return revisions[index]
+        except IndexError:
+            return None
 \f    
-rcs.make_rcs_testcase_subclasses(Darcs, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Darcs, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index ba48efc568d34d852b6bb51d2d028aad0465d119..9253a23a99e819fd3030ac6f30bf09f21bba9f81 100644 (file)
 # 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.
-"""Compare two bug trees"""
-from libbe import cmdutil, bugdir, bug
-from libbe.utility import time_to_str
+
+"""Compare two bug trees."""
+
+import difflib
 import doctest
 
-def bug_diffs(old_bugdir, new_bugdir):
-    added = []
-    removed = []
-    modified = []
-    for uuid in old_bugdir.list_uuids():
-        old_bug = old_bugdir.bug_from_uuid(uuid)
-        try:
-            new_bug = new_bugdir.bug_from_uuid(uuid)
-            old_bug.load_comments()
-            new_bug.load_comments()
-            if old_bug != new_bug:
-                modified.append((old_bug, new_bug))
-        except KeyError:
-            removed.append(old_bug)
-    for uuid in new_bugdir.list_uuids():
-        if not old_bugdir.has_bug(uuid):
-            new_bug = new_bugdir.bug_from_uuid(uuid)
-            added.append(new_bug)
-    return (removed, modified, added)
+from libbe import bugdir, bug, settings_object, tree
+from libbe.utility import time_to_str
 
-def diff_report(bug_diffs_data, old_bugdir, new_bugdir):
-    bugs_removed,bugs_modified,bugs_added = bug_diffs_data
-    def modified_cmp(left, right):
-        return bug.cmp_severity(left[1], right[1])
 
-    bugs_added.sort(bug.cmp_severity)
-    bugs_removed.sort(bug.cmp_severity)
-    bugs_modified.sort(modified_cmp)
-    lines = []
-    
-    if old_bugdir.settings != new_bugdir.settings:
-        bugdir_settings = sorted(new_bugdir.settings_properties)
-        bugdir_settings.remove("rcs_name") # tweaked by bugdir.duplicate_bugdir
-        change_list = change_lines(old_bugdir, new_bugdir, bugdir_settings)
-        if len(change_list) >  0:
-            lines.append("Modified bug directory:")
-            change_strings = ["%s: %s -> %s" % f for f in change_list]
-            lines.extend(change_strings)
-            lines.append("")
-    if len(bugs_added) > 0:
-        lines.append("New bug reports:")
-        for bg in bugs_added:
-            lines.extend(bg.string(shortlist=True).splitlines())
-        lines.append("")
-    if len(bugs_modified) > 0:
-        printed = False
-        for old_bug, new_bug in bugs_modified:
-            change_str = bug_changes(old_bug, new_bug)
-            if change_str is None:
-                continue
-            if not printed:
-                printed = True
-                lines.append("Modified bug reports:")
-            lines.extend(change_str.splitlines())
-        if printed == True:
-            lines.append("")
-    if len(bugs_removed) > 0:
-        lines.append("Removed bug reports:")
-        for bg in bugs_removed:
-            lines.extend(bg.string(shortlist=True).splitlines())
-        lines.append("")
-    
-    return "\n".join(lines).rstrip("\n")
+class DiffTree (tree.Tree):
+    """
+    A tree holding difference data for easy report generation.
+    >>> bugdir = DiffTree("bugdir")
+    >>> bdsettings = DiffTree("settings", data="target: None -> 1.0")
+    >>> bugdir.append(bdsettings)
+    >>> bugs = DiffTree("bugs", "bug-count: 5 -> 6")
+    >>> bugdir.append(bugs)
+    >>> new = DiffTree("new", "new bugs: ABC, DEF")
+    >>> bugs.append(new)
+    >>> rem = DiffTree("rem", "removed bugs: RST, UVW")
+    >>> bugs.append(rem)
+    >>> print bugdir.report_string()
+    target: None -> 1.0
+    bug-count: 5 -> 6
+      new bugs: ABC, DEF
+      removed bugs: RST, UVW
+    >>> print "\\n".join(bugdir.paths())
+    bugdir
+    bugdir/settings
+    bugdir/bugs
+    bugdir/bugs/new
+    bugdir/bugs/rem
+    >>> bugdir.child_by_path("/") == bugdir
+    True
+    >>> bugdir.child_by_path("/bugs") == bugs
+    True
+    >>> bugdir.child_by_path("/bugs/rem") == rem
+    True
+    >>> bugdir.child_by_path("bugdir") == bugdir
+    True
+    >>> bugdir.child_by_path("bugdir/") == bugdir
+    True
+    >>> bugdir.child_by_path("bugdir/bugs") == bugs
+    True
+    >>> bugdir.child_by_path("/bugs").masked = True
+    >>> print bugdir.report_string()
+    target: None -> 1.0
+    """
+    def __init__(self, name, data=None, data_part_fn=str,
+                 requires_children=False, masked=False):
+        tree.Tree.__init__(self)
+        self.name = name
+        self.data = data
+        self.data_part_fn = data_part_fn
+        self.requires_children = requires_children
+        self.masked = masked
+    def paths(self, parent_path=None):
+        paths = []
+        if parent_path == None:
+            path = self.name
+        else:
+            path = "%s/%s" % (parent_path, self.name)
+        paths.append(path)
+        for child in self:
+            paths.extend(child.paths(path))
+        return paths
+    def child_by_path(self, path):
+        if hasattr(path, "split"): # convert string path to a list of names
+            names = path.split("/")
+            if names[0] == "":
+                names[0] = self.name # replace root with self
+            if len(names) > 1 and names[-1] == "":
+                names = names[:-1] # strip empty tail
+        else: # it was already an array
+            names = path
+        assert len(names) > 0, path
+        if names[0] == self.name:
+            if len(names) == 1:
+                return self
+            for child in self:
+                if names[1] == child.name:
+                    return child.child_by_path(names[1:])
+        if len(names) == 1:
+            raise KeyError, "%s doesn't match '%s'" % (names, self.name)
+        raise KeyError, "%s points to child not in %s" % (names, [c.name for c in self])
+    def report_string(self):
+        return "\n".join(self.report())
+    def report(self, root=None, parent=None, depth=0):
+        if root == None:
+            root = self.make_root()
+        if self.masked == True:
+            return None
+        data_part = self.data_part(depth)
+        if self.requires_children == True and len(self) == 0:
+            pass
+        else:
+            self.join(root, parent, data_part)
+            if data_part != None:
+                depth += 1
+        for child in self:
+            child.report(root, self, depth)
+        return root
+    def make_root(self):
+        return []
+    def join(self, root, parent, data_part):
+        if data_part != None:
+            root.append(data_part)
+    def data_part(self, depth, indent=True):
+        if self.data == None:
+            return None
+        if hasattr(self, "_cached_data_part"):
+            return self._cached_data_part
+        data_part = self.data_part_fn(self.data)
+        if indent == True:
+            data_part_lines = data_part.splitlines()
+            indent = "  "*(depth)
+            line_sep = "\n"+indent
+            data_part = indent+line_sep.join(data_part_lines)
+        self._cached_data_part = data_part
+        return data_part
 
-def change_lines(old, new, attributes):
-    change_list = []    
-    for attr in attributes:
-        old_attr = getattr(old, attr)
-        new_attr = getattr(new, attr)
-        if old_attr != new_attr:
-            change_list.append((attr, old_attr, new_attr))
-    if len(change_list) >= 0:
-        return change_list
-    else:
+class Diff (object):
+    """
+    Difference tree generator for BugDirs.
+    >>> import copy
+    >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
+    >>> bd.user_id = "John Doe <j@doe.com>"
+    >>> bd_new = copy.deepcopy(bd)
+    >>> bd_new.target = "1.0"
+    >>> a = bd_new.bug_from_uuid("a")
+    >>> rep = a.comment_root.new_reply("I'm closing this bug")
+    >>> rep.uuid = "acom"
+    >>> rep.date = "Thu, 01 Jan 1970 00:00:00 +0000"
+    >>> a.status = "closed"
+    >>> b = bd_new.bug_from_uuid("b")
+    >>> bd_new.remove_bug(b)
+    >>> c = bd_new.new_bug("c", "Bug C")
+    >>> d = Diff(bd, bd_new)
+    >>> r = d.report_tree()
+    >>> print "\\n".join(r.paths())
+    bugdir
+    bugdir/settings
+    bugdir/bugs
+    bugdir/bugs/new
+    bugdir/bugs/new/c
+    bugdir/bugs/rem
+    bugdir/bugs/rem/b
+    bugdir/bugs/mod
+    bugdir/bugs/mod/a
+    bugdir/bugs/mod/a/settings
+    bugdir/bugs/mod/a/comments
+    bugdir/bugs/mod/a/comments/new
+    bugdir/bugs/mod/a/comments/new/acom
+    bugdir/bugs/mod/a/comments/rem
+    bugdir/bugs/mod/a/comments/mod
+    >>> print r.report_string()
+    Changed bug directory settings:
+      target: None -> 1.0
+    New bugs:
+      c:om: Bug C
+    Removed bugs:
+      b:cm: Bug B
+    Modified bugs:
+      a:cm: Bug A
+        Changed bug settings:
+          status: open -> closed
+        New comments:
+          from John Doe <j@doe.com> on Thu, 01 Jan 1970 00:00:00 +0000
+            I'm closing this bug...
+    >>> bd.cleanup()
+    """
+    def __init__(self, old_bugdir, new_bugdir):
+        self.old_bugdir = old_bugdir
+        self.new_bugdir = new_bugdir
+
+    # data assembly methods
+
+    def _changed_bugs(self):
+        """
+        Search for differences in all bugs between .old_bugdir and
+        .new_bugdir.  Returns
+          (added_bugs, modified_bugs, removed_bugs)
+        where added_bugs and removed_bugs are lists of added and
+        removed bugs respectively.  modified_bugs is a list of
+        (old_bug,new_bug) pairs.
+        """
+        if hasattr(self, "__changed_bugs"):
+            return self.__changed_bugs
+        added = []
+        removed = []
+        modified = []
+        for uuid in self.new_bugdir.list_uuids():
+            new_bug = self.new_bugdir.bug_from_uuid(uuid)
+            try:
+                old_bug = self.old_bugdir.bug_from_uuid(uuid)
+            except KeyError:
+                added.append(new_bug)
+            else:
+                if old_bug.sync_with_disk == True:
+                    old_bug.load_comments()
+                if new_bug.sync_with_disk == True:
+                    new_bug.load_comments()
+                if old_bug != new_bug:
+                    modified.append((old_bug, new_bug))
+        for uuid in self.old_bugdir.list_uuids():
+            if not self.new_bugdir.has_bug(uuid):
+                old_bug = self.old_bugdir.bug_from_uuid(uuid)
+                removed.append(old_bug)
+        added.sort()
+        removed.sort()
+        modified.sort(self._bug_modified_cmp)
+        self.__changed_bugs = (added, modified, removed)
+        return self.__changed_bugs
+    def _bug_modified_cmp(self, left, right):
+        return cmp(left[1], right[1])
+    def _changed_comments(self, old, new):
+        """
+        Search for differences in all loaded comments between the bugs
+        old and new.  Returns
+          (added_comments, modified_comments, removed_comments)
+        analogous to ._changed_bugs.
+        """
+        if hasattr(self, "__changed_comments"):
+            if new.uuid in self.__changed_comments:
+                return self.__changed_comments[new.uuid]
+        else:
+            self.__changed_comments = {}
+        added = []
+        removed = []
+        modified = []
+        old.comment_root.sort(key=lambda comm : comm.time)
+        new.comment_root.sort(key=lambda comm : comm.time)
+        old_comment_ids = [c.uuid for c in old.comments()]
+        new_comment_ids = [c.uuid for c in new.comments()]
+        for uuid in new_comment_ids:
+            new_comment = new.comment_from_uuid(uuid)
+            try:
+                old_comment = old.comment_from_uuid(uuid)
+            except KeyError:
+                added.append(new_comment)
+            else:
+                if old_comment != new_comment:
+                    modified.append((old_comment, new_comment))
+        for uuid in old_comment_ids:
+            if uuid not in new_comment_ids:
+                new_comment = new.comment_from_uuid(uuid)
+                removed.append(new_comment)
+        self.__changed_comments[new.uuid] = (added, modified, removed)
+        return self.__changed_comments[new.uuid]
+    def _attribute_changes(self, old, new, attributes):
+        """
+        Take two objects old and new, and compare the value of *.attr
+        for attr in the list attribute names.  Returns a list of
+          (attr_name, old_value, new_value)
+        tuples.
+        """
+        change_list = []
+        for attr in attributes:
+            old_value = getattr(old, attr)
+            new_value = getattr(new, attr)
+            if old_value != new_value:
+                change_list.append((attr, old_value, new_value))
+        if len(change_list) >= 0:
+            return change_list
         return None
+    def _settings_properties_attribute_changes(self, old, new,
+                                              hidden_properties=[]):
+        properties = sorted(new.settings_properties)
+        for p in hidden_properties:
+            properties.remove(p)
+        attributes = [settings_object.setting_name_to_attr_name(None, p)
+                      for p in properties]
+        return self._attribute_changes(old, new, attributes)
+    def _bugdir_attribute_changes(self):
+        return self._settings_properties_attribute_changes( \
+            self.old_bugdir, self.new_bugdir,
+            ["vcs_name"]) # tweaked by bugdir.duplicate_bugdir
+    def _bug_attribute_changes(self, old, new):
+        return self._settings_properties_attribute_changes(old, new)
+    def _comment_attribute_changes(self, old, new):
+        return self._settings_properties_attribute_changes(old, new)
 
-def bug_changes(old, new):
-    bug_settings = sorted(new.settings_properties)
-    change_list = change_lines(old, new, bug_settings)
-    change_strings = ["%s: %s -> %s" % f for f in change_list]
+    # report generation methods
 
-    old_comment_ids = [c.uuid for c in old.comments()]
-    new_comment_ids = [c.uuid for c in new.comments()]
-    for comment_id in new_comment_ids:
-        if comment_id not in old_comment_ids:
-            summary = comment_summary(new.comment_from_uuid(comment_id), "new")
-            change_strings.append(summary)
-    for comment_id in old_comment_ids:
-        if comment_id not in new_comment_ids:
-            summary = comment_summary(new.comment_from_uuid(comment_id),
-                                      "removed")
-            change_strings.append(summary)
+    def report_tree(self, diff_tree=DiffTree):
+        """
+        Pretty bare to make it easy to adjust to specific cases.  You
+        can pass in a DiffTree subclass via diff_tree to override the
+        default report assembly process.
+        """
+        if hasattr(self, "__report_tree"):
+            return self.__report_tree
+        bugdir_settings = sorted(self.new_bugdir.settings_properties)
+        bugdir_settings.remove("vcs_name") # tweaked by bugdir.duplicate_bugdir
+        root = diff_tree("bugdir")
+        bugdir_attribute_changes = self._bugdir_attribute_changes()
+        if len(bugdir_attribute_changes) > 0:
+            bugdir = diff_tree("settings", bugdir_attribute_changes,
+                               self.bugdir_attribute_change_string)
+            root.append(bugdir)
+        bug_root = diff_tree("bugs")
+        root.append(bug_root)
+        add,mod,rem = self._changed_bugs()
+        bnew = diff_tree("new", "New bugs:", requires_children=True)
+        bug_root.append(bnew)
+        for bug in add:
+            b = diff_tree(bug.uuid, bug, self.bug_add_string)
+            bnew.append(b)
+        brem = diff_tree("rem", "Removed bugs:", requires_children=True)
+        bug_root.append(brem)
+        for bug in rem:
+            b = diff_tree(bug.uuid, bug, self.bug_rem_string)
+            brem.append(b)
+        bmod = diff_tree("mod", "Modified bugs:", requires_children=True)
+        bug_root.append(bmod)
+        for old,new in mod:
+            b = diff_tree(new.uuid, (old,new), self.bug_mod_string)
+            bmod.append(b)
+            bug_attribute_changes = self._bug_attribute_changes(old, new)
+            if len(bug_attribute_changes) > 0:
+                bset = diff_tree("settings", bug_attribute_changes,
+                                 self.bug_attribute_change_string)
+                b.append(bset)
+            if old.summary != new.summary:
+                data = (old.summary, new.summary)
+                bsum = diff_tree("summary", data, self.bug_summary_change_string)
+                b.append(bsum)
+            cr = diff_tree("comments")
+            b.append(cr)
+            a,m,d = self._changed_comments(old, new)
+            cnew = diff_tree("new", "New comments:", requires_children=True)
+            for comment in a:
+                c = diff_tree(comment.uuid, comment, self.comment_add_string)
+                cnew.append(c)
+            crem = diff_tree("rem", "Removed comments:",requires_children=True)
+            for comment in d:
+                c = diff_tree(comment.uuid, comment, self.comment_rem_string)
+                crem.append(c)
+            cmod = diff_tree("mod","Modified comments:",requires_children=True)
+            for o,n in m:
+                c = diff_tree(n.uuid, (o,n), self.comment_mod_string)
+                cmod.append(c)
+                comm_attribute_changes = self._comment_attribute_changes(o, n)
+                if len(comm_attribute_changes) > 0:
+                    cset = diff_tree("settings", comm_attribute_changes,
+                                     self.comment_attribute_change_string)
+                if o.body != n.body:
+                    data = (o.body, n.body)
+                    cbody = diff_tree("cbody", data,
+                                      self.comment_body_change_string)
+                    c.append(cbody)
+            cr.extend([cnew, crem, cmod])
+        self.__report_tree = root
+        return self.__report_tree
 
-    if len(change_strings) == 0:
-        return None
-    return "%s\n  %s" % (new.string(shortlist=True),
-                         "  \n".join(change_strings))
+    # change data -> string methods.
+    # Feel free to play with these in subclasses.
 
+    def attribute_change_string(self, attribute_changes, indent=0):
+        indent_string = "  "*indent
+        change_strings = [u"%s: %s -> %s" % f for f in attribute_changes]
+        for i,change_string in enumerate(change_strings):
+            change_strings[i] = indent_string+change_string
+        return u"\n".join(change_strings)
+    def bugdir_attribute_change_string(self, attribute_changes):
+        return "Changed bug directory settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def bug_attribute_change_string(self, attribute_changes):
+        return "Changed bug settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def comment_attribute_change_string(self, attribute_changes):
+        return "Changed comment settings:\n%s" % \
+            self.attribute_change_string(attribute_changes, indent=1)
+    def bug_add_string(self, bug):
+        return bug.string(shortlist=True)
+    def bug_rem_string(self, bug):
+        return bug.string(shortlist=True)
+    def bug_mod_string(self, bugs):
+        old_bug,new_bug = bugs
+        return new_bug.string(shortlist=True)
+    def bug_summary_change_string(self, summaries):
+        old_summary,new_summary = summaries
+        return "summary changed:\n  %s\n  %s" % (old_summary, new_summary)
+    def _comment_summary_string(self, comment):
+        return "from %s on %s" % (comment.author, time_to_str(comment.time))
+    def comment_add_string(self, comment):
+        summary = self._comment_summary_string(comment)
+        first_line = comment.body.splitlines()[0]
+        return "%s\n  %s..." % (summary, first_line)
+    def comment_rem_string(self, comment):
+        summary = self._comment_summary_string(comment)
+        first_line = comment.body.splitlines()[0]
+        return "%s\n  %s..." % (summary, first_line)
+    def comment_mod_string(self, comments):
+        old_comment,new_comment = comments
+        return self._comment_summary_string(new_comment)
+    def comment_body_change_string(self, bodies):
+        old_body,new_body = bodies
+        return difflib.unified_diff(old_body, new_body)
 
-def comment_summary(comment, status):
-    return "%8s comment from %s on %s" % (status, comment.From, 
-                                          time_to_str(comment.time))
 
 suite = doctest.DocTestSuite()
index 93144b82001aeb276424643c73d5f9ceaafaf45a..ec410061fd79b384e4b83165c538966cf88b49dd 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Define editor_string(), a function that invokes an editor to accept
+user-produced text as a string.
+"""
+
 import codecs
 import locale
 import os
@@ -22,6 +27,7 @@ import sys
 import tempfile
 import doctest
 
+
 default_encoding = sys.getfilesystemencoding() or locale.getpreferredencoding()
 
 comment_marker = u"== Anything below this line will be ignored\n"
@@ -62,7 +68,8 @@ def editor_string(comment=None, encoding=None):
     fhandle, fname = tempfile.mkstemp()
     try:
         if comment is not None:
-            os.write(fhandle, '\n'+comment_string(comment))
+            cstring = u'\n'+comment_string(comment)
+            os.write(fhandle, cstring.encode(encoding))
         os.close(fhandle)
         oldmtime = os.path.getmtime(fname)
         os.system("%s %s" % (editor, fname))
index d6036022be583e247d211e6da897ef5e60f08565..fd513b56331aac3f898153c57da9c1396caddcca 100644 (file)
 # 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.
+
+"""
+Support input/output/filesystem encodings (e.g. UTF-8).
+"""
+
 import codecs
 import locale
 import sys
 import doctest
 
+
+ENCODING = None # override get_encoding() output by setting this
+
 def get_encoding():
     """
     Guess a useful input/output/filesystem encoding...  Maybe we need
     seperate encodings for input/output and filesystem?  Hmm...
     """
+    if ENCODING != None:
+        return ENCODING
     encoding = locale.getpreferredencoding() or sys.getdefaultencoding()
     if sys.platform != 'win32' or sys.version_info[:2] > (2, 3):
         encoding = locale.getlocale(locale.LC_TIME)[1] or encoding
index 2f9ffa9500f97ba7bb0e8d4d50980b82b07bcc64..3abe3b816bd4bc5c63ed22f7578e7fe62391e416 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Git backend.
+"""
+
 import os
 import re
 import sys
 import unittest
 import doctest
 
-import rcs
-from rcs import RCS
+import vcs
+
 
 def new():
     return Git()
 
-class Git(RCS):
+class Git(vcs.VCS):
     name="git"
     client="git"
     versioned=True
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         if self._u_search_parent_directories(path, ".git") != None :
             return True
         return False 
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         """Find the root of the deepest repository containing path."""
         # Assume that nothing funny is going on; in particular, that we aren't
         # dealing with a bare repo.
@@ -50,13 +54,21 @@ class Git(RCS):
         gitdir = os.path.join(path, output.rstrip('\n'))
         dirname = os.path.abspath(os.path.dirname(gitdir))
         return dirname
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._u_invoke_client("init", directory=path)
-    def _rcs_get_user_id(self):
-        status,output,error = self._u_invoke_client("config", "user.name")
-        name = output.rstrip('\n')
-        status,output,error = self._u_invoke_client("config", "user.email")
-        email = output.rstrip('\n')
+    def _vcs_get_user_id(self):
+        status,output,error = \
+            self._u_invoke_client("config", "user.name", expect=(0,1))
+        if status == 0:
+            name = output.rstrip('\n')
+        else:
+            name = ""
+        status,output,error = \
+            self._u_invoke_client("config", "user.email", expect=(0,1))
+        if status == 0:
+            email = output.rstrip('\n')
+        else:
+            email = ""
         if name != "" or email != "": # got something!
             # guess missing info, if necessary
             if name == "":
@@ -65,35 +77,35 @@ class Git(RCS):
                 email = self._u_get_fallback_email()
             return self._u_create_id(name, email)
         return None # Git has no infomation
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         name,email = self._u_parse_id(value)
         if email != None:
             self._u_invoke_client("config", "user.email", email)
         self._u_invoke_client("config", "user.name", name)
-    def _rcs_add(self, path):
+    def _vcs_add(self, path):
         if os.path.isdir(path):
             return
         self._u_invoke_client("add", path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         if not os.path.isdir(self._u_abspath(path)):
             self._u_invoke_client("rm", "-f", path)
-    def _rcs_update(self, path):
-        self._rcs_add(path)
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_update(self, path):
+        self._vcs_add(path)
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision, binary=binary)
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
         else:
             arg = "%s:%s" % (revision,path)
             status,output,error = self._u_invoke_client("show", arg)
             return output
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision==None:
-            RCS._rcs_duplicate_repo(self, directory, revision)
+            vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             #self._u_invoke_client("archive", revision, directory) # makes tarball
             self._u_invoke_client("clone", "--no-checkout",".",directory)
             self._u_invoke_client("checkout", revision, directory=directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         args = ['commit', '--all', '--file', commitfile]
         if allow_empty == True:
             args.append("--allow-empty")
@@ -104,17 +116,33 @@ class Git(RCS):
             strings = ["nothing to commit",
                        "nothing added to commit"]
             if self._u_any_in_string(strings, output) == True:
-                raise rcs.EmptyCommit()
+                raise vcs.EmptyCommit()
         revision = None
         revline = re.compile("(.*) (.*)[:\]] (.*)")
         match = revline.search(output)
         assert match != None, output+error
         assert len(match.groups()) == 3
         revision = match.groups()[1]
-        return revision
+        full_revision = self._vcs_revision_id(-1)
+        assert full_revision.startswith(revision), \
+            "Mismatched revisions:\n%s\n%s" % (revision, full_revision)
+        return full_revision
+    def _vcs_revision_id(self, index):
+        args = ["rev-list", "--first-parent", "--reverse", "HEAD"]
+        kwargs = {"expect":(0,128)}
+        status,output,error = self._u_invoke_client(*args, **kwargs)
+        if status == 128:
+            if error.startswith("fatal: ambiguous argument 'HEAD': unknown "):
+                return None
+            raise vcs.CommandError(args, status, stdout="", stderr=error)
+        commits = output.splitlines()
+        try:
+            return commits[index]
+        except IndexError:
+            return None
 
 \f    
-rcs.make_rcs_testcase_subclasses(Git, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Git, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index a20eeb5ca73f49e3daf7c4759c69cced8de37786..f8f8121dd9f63669e7bbfb7a592427ecc6c1105a 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Mercurial (hg) backend.
+"""
+
 import os
 import re
 import sys
 import unittest
 import doctest
 
-import rcs
-from rcs import RCS
+import vcs
+
 
 def new():
     return Hg()
 
-class Hg(RCS):
+class Hg(vcs.VCS):
     name="hg"
     client="hg"
     versioned=True
-    def _rcs_help(self):
+    def _vcs_help(self):
         status,output,error = self._u_invoke_client("--help")
         return output
-    def _rcs_detect(self, path):
+    def _vcs_detect(self, path):
         """Detect whether a directory is revision-controlled using Mercurial"""
         if self._u_search_parent_directories(path, ".hg") != None:
             return True
         return False
-    def _rcs_root(self, path):
+    def _vcs_root(self, path):
         status,output,error = self._u_invoke_client("root", directory=path)
         return output.rstrip('\n')
-    def _rcs_init(self, path):
+    def _vcs_init(self, path):
         self._u_invoke_client("init", directory=path)
-    def _rcs_get_user_id(self):
+    def _vcs_get_user_id(self):
         status,output,error = self._u_invoke_client("showconfig","ui.username")
         return output.rstrip('\n')
-    def _rcs_set_user_id(self, value):
+    def _vcs_set_user_id(self, value):
         """
         Supported by the Config Extension, but that is not part of
         standard Mercurial.
         http://www.selenic.com/mercurial/wiki/index.cgi/ConfigExtension
         """
-        raise rcs.SettingIDnotSupported
-    def _rcs_add(self, path):
+        raise vcs.SettingIDnotSupported
+    def _vcs_add(self, path):
         self._u_invoke_client("add", path)
-    def _rcs_remove(self, path):
+    def _vcs_remove(self, path):
         self._u_invoke_client("rm", "--force", path)
-    def _rcs_update(self, path):
+    def _vcs_update(self, path):
         pass
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
         if revision == None:
-            return RCS._rcs_get_file_contents(self, path, revision, binary=binary)
+            return vcs.VCS._vcs_get_file_contents(self, path, revision, binary=binary)
         else:
             status,output,error = \
                 self._u_invoke_client("cat","-r",revision,path)
             return output
-    def _rcs_duplicate_repo(self, directory, revision=None):
+    def _vcs_duplicate_repo(self, directory, revision=None):
         if revision == None:
-            return RCS._rcs_duplicate_repo(self, directory, revision)
+            return vcs.VCS._vcs_duplicate_repo(self, directory, revision)
         else:
             self._u_invoke_client("archive", "--rev", revision, directory)
-    def _rcs_commit(self, commitfile, allow_empty=False):
+    def _vcs_commit(self, commitfile, allow_empty=False):
         args = ['commit', '--logfile', commitfile]
         status,output,error = self._u_invoke_client(*args)
         if allow_empty == False:
             strings = ["nothing changed"]
             if self._u_any_in_string(strings, output) == True:
-                raise rcs.EmptyCommit()
-        status,output,error = self._u_invoke_client('identify')
-        revision = None
-        revline = re.compile("(.*) tip")
-        match = revline.search(output)
-        assert match != None, output+error
-        assert len(match.groups()) == 1
-        revision = match.groups()[0]
-        return revision
+                raise vcs.EmptyCommit()
+        return self._vcs_revision_id(-1)
+    def _vcs_revision_id(self, index, style="id"):
+        args = ["identify", "--rev", str(int(index)), "--%s" % style]
+        kwargs = {"expect": (0,255)}
+        status,output,error = self._u_invoke_client(*args, **kwargs)
+        if status == 0:
+            id = output.strip()
+            if id == '000000000000':
+                return None # before initial commit.
+            return id
+        return None
 
 \f    
-rcs.make_rcs_testcase_subclasses(Hg, sys.modules[__name__])
+vcs.make_vcs_testcase_subclasses(Hg, sys.modules[__name__])
 
 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index b959d7608c8abec2d5c0644585b76d0b936c050c..4d696013a6c6a0fb591caa440c63233207a51f43 100644 (file)
 # 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 yaml
-import os.path
+
+"""
+Provide a means of saving and loading dictionaries of parameters.  The
+saved "mapfiles" should be clear, flat-text files, and allow easy merging of
+independent/conflicting changes.
+"""
+
 import errno
-import utility
+import os.path
+import yaml
 import doctest
 
+
 class IllegalKey(Exception):
     def __init__(self, key):
         Exception.__init__(self, 'Illegal key "%s"' % key)
@@ -95,33 +102,15 @@ def parse(contents):
     >>> dict["e"]
     'f'
     """
-    old_format = False
-    for line in contents.splitlines():
-        if len(line.split("=")) == 2:
-            old_format = True
-            break
-    if old_format: # translate to YAML.  Hack to deal with old BE bugs.
-        newlines = []
-        for line in contents.splitlines():
-            line = line.rstrip('\n')
-            if len(line) == 0:
-                continue
-            fields = line.split("=")
-            if len(fields) == 2:
-                key,value = fields
-                newlines.append('%s: "%s"' % (key, value.replace('"','\\"')))
-            else:
-                newlines.append(line)
-        contents = '\n'.join(newlines)
     return yaml.load(contents) or {}
 
-def map_save(rcs, path, map, allow_no_rcs=False):
+def map_save(vcs, path, map, allow_no_vcs=False):
     """Save the map as a mapfile to the specified path"""
     contents = generate(map)
-    rcs.set_file_contents(path, contents, allow_no_rcs)
+    vcs.set_file_contents(path, contents, allow_no_vcs)
 
-def map_load(rcs, path, allow_no_rcs=False):
-    contents = rcs.get_file_contents(path, allow_no_rcs=allow_no_rcs)
+def map_load(vcs, path, allow_no_vcs=False):
+    contents = vcs.get_file_contents(path, allow_no_vcs=allow_no_vcs)
     return parse(contents)
 
 suite = doctest.DocTestSuite()
index 0545fd796f12f3d79c9f981d562095b82640fa62..d593d69132b115e205b99da55ff5ed72a6f03b02 100644 (file)
 # 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.
+
+"""
+Allow simple listing and loading of the various becommands and libbe
+submodules (i.e. "plugins").
+"""
+
 import os
 import os.path
 import sys
index 144220b181d0d06df08062d773da1a6c7687df39..09dd20e0417de099434d038f6ba254a2ba457323 100644 (file)
@@ -160,10 +160,10 @@ def _get_cached_mutable_property(self, cacher_name, property_name, default=None)
     if (cacher_name, property_name) not in self._mutable_property_cache_copy:
         return default
     return self._mutable_property_cache_copy[(cacher_name, property_name)]
-def _cmp_cached_mutable_property(self, cacher_name, property_name, value):
+def _cmp_cached_mutable_property(self, cacher_name, property_name, value, default=None):
     _init_mutable_property_cache(self)
     if (cacher_name, property_name) not in self._mutable_property_cache_hash:
-        return 1 # any value > non-existant old hash
+        _set_cached_mutable_property(self, cacher_name, property_name, default)
     old_hash = self._mutable_property_cache_hash[(cacher_name, property_name)]
     return cmp(_hash_mutable_value(value), old_hash)
 
@@ -327,7 +327,7 @@ def primed_property(primer, initVal=None):
         return funcs
     return decorator
 
-def change_hook_property(hook, mutable=False):
+def change_hook_property(hook, mutable=False, default=None):
     """
     Call the function hook(instance, old_value, new_value) whenever a
     value different from the current value is set (instance is a a
@@ -359,9 +359,9 @@ def change_hook_property(hook, mutable=False):
                 value = new_value # compare new value with cached
             else:
                 value = fget(self) # compare current value with cached
-            if _cmp_cached_mutable_property(self, "change hook property", name, value) != 0:
+            if _cmp_cached_mutable_property(self, "change hook property", name, value, default) != 0:
                 # there has been a change, cache new value
-                old_value = _get_cached_mutable_property(self, "change hook property", name)
+                old_value = _get_cached_mutable_property(self, "change hook property", name, default)
                 _set_cached_mutable_property(self, "change hook property", name, value)
                 if from_fset == True: # return previously cached value
                     value = old_value
index 294b8e063e9802cbfeeb1935f445134678ba6f8a..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 (file)
@@ -1,876 +0,0 @@
-# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
-#                         Alexander Belchenko <bialix@ukr.net>
-#                         Ben Finney <ben+python@benfinney.id.au>
-#                         Chris Ball <cjb@laptop.org>
-#                         W. Trevor King <wking@drexel.edu>
-#
-# 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.
-
-from subprocess import Popen, PIPE
-import codecs
-import os
-import os.path
-import re
-from socket import gethostname
-import shutil
-import sys
-import tempfile
-import unittest
-import doctest
-
-from utility import Dir, search_parent_directories
-
-
-def _get_matching_rcs(matchfn):
-    """Return the first module for which matchfn(RCS_instance) is true"""
-    import arch
-    import bzr
-    import darcs
-    import git
-    import hg
-    for module in [arch, bzr, darcs, git, hg]:
-        rcs = module.new()
-        if matchfn(rcs) == True:
-            return rcs
-        del(rcs)
-    return RCS()
-    
-def rcs_by_name(rcs_name):
-    """Return the module for the RCS with the given name"""
-    return _get_matching_rcs(lambda rcs: rcs.name == rcs_name)
-
-def detect_rcs(dir):
-    """Return an RCS instance for the rcs being used in this directory"""
-    return _get_matching_rcs(lambda rcs: rcs.detect(dir))
-
-def installed_rcs():
-    """Return an instance of an installed RCS"""
-    return _get_matching_rcs(lambda rcs: rcs.installed())
-
-
-class CommandError(Exception):
-    def __init__(self, command, status, err_str):
-        strerror = ["Command failed (%d):\n  %s\n" % (status, err_str),
-                    "while executing\n  %s" % command]
-        Exception.__init__(self, "\n".join(strerror))
-        self.command = command
-        self.status = status
-        self.err_str = err_str
-
-class SettingIDnotSupported(NotImplementedError):
-    pass
-
-class RCSnotRooted(Exception):
-    def __init__(self):
-        msg = "RCS not rooted"
-        Exception.__init__(self, msg)
-
-class PathNotInRoot(Exception):
-    def __init__(self, path, root):
-        msg = "Path '%s' not in root '%s'" % (path, root)
-        Exception.__init__(self, msg)
-        self.path = path
-        self.root = root
-
-class NoSuchFile(Exception):
-    def __init__(self, pathname, root="."):
-        path = os.path.abspath(os.path.join(root, pathname))
-        Exception.__init__(self, "No such file: %s" % path)
-
-class EmptyCommit(Exception):
-    def __init__(self):
-        Exception.__init__(self, "No changes to commit")
-
-
-def new():
-    return RCS()
-
-class RCS(object):
-    """
-    This class implements a 'no-rcs' interface.
-
-    Support for other RCSs can be added by subclassing this class, and
-    overriding methods _rcs_*() with code appropriate for your RCS.
-    
-    The methods _u_*() are utility methods available to the _rcs_*()
-    methods.
-    """
-    name = "None"
-    client = "" # command-line tool for _u_invoke_client
-    versioned = False
-    def __init__(self, paranoid=False, encoding=sys.getdefaultencoding()):
-        self.paranoid = paranoid
-        self.verboseInvoke = False
-        self.rootdir = None
-        self._duplicateBasedir = None
-        self._duplicateDirname = None
-        self.encoding = encoding
-    def __del__(self):
-        self.cleanup()
-
-    def _rcs_help(self):
-        """
-        Return the command help string.
-        (Allows a simple test to see if the client is installed.)
-        """
-        pass
-    def _rcs_detect(self, path=None):
-        """
-        Detect whether a directory is revision controlled with this RCS.
-        """
-        return True
-    def _rcs_root(self, path):
-        """
-        Get the RCS root.  This is the default working directory for
-        future invocations.  You would normally set this to the root
-        directory for your RCS.
-        """
-        if os.path.isdir(path)==False:
-            path = os.path.dirname(path)
-            if path == "":
-                path = os.path.abspath(".")
-        return path
-    def _rcs_init(self, path):
-        """
-        Begin versioning the tree based at path.
-        """
-        pass
-    def _rcs_cleanup(self):
-        """
-        Remove any cruft that _rcs_init() created outside of the
-        versioned tree.
-        """
-        pass
-    def _rcs_get_user_id(self):
-        """
-        Get the RCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
-        If the RCS has not been configured with a username, return None.
-        """
-        return None
-    def _rcs_set_user_id(self, value):
-        """
-        Set the RCS's suggested user id (e.g "John Doe <jdoe@example.com>").
-        This is run if the RCS has not been configured with a usename, so
-        that commits will have a reasonable FROM value.
-        """
-        raise SettingIDnotSupported
-    def _rcs_add(self, path):
-        """
-        Add the already created file at path to version control.
-        """
-        pass
-    def _rcs_remove(self, path):
-        """
-        Remove the file at path from version control.  Optionally
-        remove the file from the filesystem as well.
-        """
-        pass
-    def _rcs_update(self, path):
-        """
-        Notify the versioning system of changes to the versioned file
-        at path.
-        """
-        pass
-    def _rcs_get_file_contents(self, path, revision=None, binary=False):
-        """
-        Get the file contents as they were in a given revision.
-        Revision==None specifies the current revision.
-        """
-        assert revision == None, \
-            "The %s RCS does not support revision specifiers" % self.name
-        if binary == False:
-            f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding)
-        else:
-            f = open(os.path.join(self.rootdir, path), "rb")
-        contents = f.read()
-        f.close()
-        return contents
-    def _rcs_duplicate_repo(self, directory, revision=None):
-        """
-        Get the repository as it was in a given revision.
-        revision==None specifies the current revision.
-        dir specifies a directory to create the duplicate in.
-        """
-        shutil.copytree(self.rootdir, directory, True)
-    def _rcs_commit(self, commitfile, allow_empty=False):
-        """
-        Commit the current working directory, using the contents of
-        commitfile as the comment.  Return the name of the old
-        revision (or None if commits are not supported).
-        
-        If allow_empty == False, raise EmptyCommit if there are no
-        changes to commit.
-        """
-        return None
-    def installed(self):
-        try:
-            self._rcs_help()
-            return True
-        except OSError, e:
-            if e.errno == errno.ENOENT:
-                return False
-        except CommandError:
-            return False
-    def detect(self, path="."):
-        """
-        Detect whether a directory is revision controlled with this RCS.
-        """
-        return self._rcs_detect(path)
-    def root(self, path):
-        """
-        Set the root directory to the path's RCS root.  This is the
-        default working directory for future invocations.
-        """
-        self.rootdir = self._rcs_root(path)
-    def init(self, path):
-        """
-        Begin versioning the tree based at path.
-        Also roots the rcs at path.
-        """
-        if os.path.isdir(path)==False:
-            path = os.path.dirname(path)
-        self._rcs_init(path)
-        self.root(path)
-    def cleanup(self):
-        self._rcs_cleanup()
-    def get_user_id(self):
-        """
-        Get the RCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
-        If the RCS has not been configured with a username, return the user's
-        id.  You can override the automatic lookup procedure by setting the
-        RCS.user_id attribute to a string of your choice.
-        """
-        if hasattr(self, "user_id"):
-            if self.user_id != None:
-                return self.user_id
-        id = self._rcs_get_user_id()
-        if id == None:
-            name = self._u_get_fallback_username()
-            email = self._u_get_fallback_email()
-            id = self._u_create_id(name, email)
-            print >> sys.stderr, "Guessing id '%s'" % id
-            try:
-                self.set_user_id(id)
-            except SettingIDnotSupported:
-                pass
-        return id
-    def set_user_id(self, value):
-        """
-        Set the RCS's suggested user id (e.g "John Doe <jdoe@example.com>").
-        This is run if the RCS has not been configured with a usename, so
-        that commits will have a reasonable FROM value.
-        """
-        self._rcs_set_user_id(value)
-    def add(self, path):
-        """
-        Add the already created file at path to version control.
-        """
-        self._rcs_add(self._u_rel_path(path))
-    def remove(self, path):
-        """
-        Remove a file from both version control and the filesystem.
-        """
-        self._rcs_remove(self._u_rel_path(path))
-        if os.path.exists(path):
-            os.remove(path)
-    def recursive_remove(self, dirname):
-        """
-        Remove a file/directory and all its decendents from both
-        version control and the filesystem.
-        """
-        if not os.path.exists(dirname):
-            raise NoSuchFile(dirname)
-        for dirpath,dirnames,filenames in os.walk(dirname, topdown=False):
-            filenames.extend(dirnames)
-            for path in filenames:
-                fullpath = os.path.join(dirpath, path)
-                if os.path.exists(fullpath) == False:
-                    continue
-                self._rcs_remove(self._u_rel_path(fullpath))
-        if os.path.exists(dirname):
-            shutil.rmtree(dirname)
-    def update(self, path):
-        """
-        Notify the versioning system of changes to the versioned file
-        at path.
-        """
-        self._rcs_update(self._u_rel_path(path))
-    def get_file_contents(self, path, revision=None, allow_no_rcs=False, binary=False):
-        """
-        Get the file as it was in a given revision.
-        Revision==None specifies the current revision.
-        """
-        if not os.path.exists(path):
-            raise NoSuchFile(path)
-        if self._use_rcs(path, allow_no_rcs):
-            relpath = self._u_rel_path(path)
-            contents = self._rcs_get_file_contents(relpath,revision,binary=binary)
-        else:
-            f = codecs.open(path, "r", self.encoding)
-            contents = f.read()
-            f.close()
-        return contents
-    def set_file_contents(self, path, contents, allow_no_rcs=False, binary=False):
-        """
-        Set the file contents under version control.
-        """
-        add = not os.path.exists(path)
-        if binary == False:
-            f = codecs.open(path, "w", self.encoding)
-        else:
-            f = open(path, "wb")
-        f.write(contents)
-        f.close()
-        
-        if self._use_rcs(path, allow_no_rcs):
-            if add:
-                self.add(path)
-            else:
-                self.update(path)
-    def mkdir(self, path, allow_no_rcs=False, check_parents=True):
-        """
-        Create (if neccessary) a directory at path under version
-        control.
-        """
-        if check_parents == True:
-            parent = os.path.dirname(path)
-            if not os.path.exists(parent): # recurse through parents
-                self.mkdir(parent, allow_no_rcs, check_parents)
-        if not os.path.exists(path):
-            os.mkdir(path)
-            if self._use_rcs(path, allow_no_rcs):
-                self.add(path)
-        else:
-            assert os.path.isdir(path)
-            if self._use_rcs(path, allow_no_rcs):
-                #self.update(path)# Don't update directories.  Changing files
-                pass              # underneath them should be sufficient.
-                
-    def duplicate_repo(self, revision=None):
-        """
-        Get the repository as it was in a given revision.
-        revision==None specifies the current revision.
-        Return the path to the arbitrary directory at the base of the new repo.
-        """
-        # Dirname in Baseir to protect against simlink attacks.
-        if self._duplicateBasedir == None:
-            self._duplicateBasedir = tempfile.mkdtemp(prefix='BErcs')
-            self._duplicateDirname = \
-                os.path.join(self._duplicateBasedir, "duplicate")
-            self._rcs_duplicate_repo(directory=self._duplicateDirname,
-                                     revision=revision)
-        return self._duplicateDirname
-    def remove_duplicate_repo(self):
-        """
-        Clean up a duplicate repo created with duplicate_repo().
-        """
-        if self._duplicateBasedir != None:
-            shutil.rmtree(self._duplicateBasedir)
-            self._duplicateBasedir = None
-            self._duplicateDirname = None
-    def commit(self, summary, body=None, allow_empty=False):
-        """
-        Commit the current working directory, with a commit message
-        string summary and body.  Return the name of the old revision
-        (or None if versioning is not supported).
-        
-        If allow_empty == False (the default), raise EmptyCommit if
-        there are no changes to commit.
-        """
-        summary = summary.strip()+'\n'
-        if body is not None:
-            summary += '\n' + body.strip() + '\n'
-        descriptor, filename = tempfile.mkstemp()
-        revision = None
-        try:
-            temp_file = os.fdopen(descriptor, 'wb')
-            temp_file.write(summary)
-            temp_file.flush()
-            self.precommit()
-            revision = self._rcs_commit(filename, allow_empty=allow_empty)
-            temp_file.close()
-            self.postcommit()
-        finally:
-            os.remove(filename)
-        return revision
-    def precommit(self):
-        """
-        Executed before all attempted commits.
-        """
-        pass
-    def postcommit(self):
-        """
-        Only executed after successful commits.
-        """
-        pass
-    def _u_any_in_string(self, list, string):
-        """
-        Return True if any of the strings in list are in string.
-        Otherwise return False.
-        """
-        for list_string in list:
-            if list_string in string:
-                return True
-        return False
-    def _u_invoke(self, args, stdin=None, expect=(0,), cwd=None):
-        """
-        expect should be a tuple of allowed exit codes.  cwd should be
-        the directory from which the command will be executed.
-        """
-        if cwd == None:
-            cwd = self.rootdir
-        if self.verboseInvoke == True:
-            print >> sys.stderr, "%s$ %s" % (cwd, " ".join(args))
-        try :
-            if sys.platform != "win32":
-                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd)
-            else:
-                # win32 don't have os.execvp() so have to run command in a shell
-                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, 
-                          shell=True, cwd=cwd)
-        except OSError, e :
-            raise CommandError(args, e.args[0], e)
-        output, error = q.communicate(input=stdin)
-        status = q.wait()
-        if self.verboseInvoke == True:
-            print >> sys.stderr, "%d\n%s%s" % (status, output, error)
-        if status not in expect:
-            raise CommandError(args, status, error)
-        return status, output, error
-    def _u_invoke_client(self, *args, **kwargs):
-        directory = kwargs.get('directory',None)
-        expect = kwargs.get('expect', (0,))
-        stdin = kwargs.get('stdin', None)
-        cl_args = [self.client]
-        cl_args.extend(args)
-        return self._u_invoke(cl_args, stdin=stdin,expect=expect,cwd=directory)
-    def _u_search_parent_directories(self, path, filename):
-        """
-        Find the file (or directory) named filename in path or in any
-        of path's parents.
-        
-        e.g.
-          search_parent_directories("/a/b/c", ".be")
-        will return the path to the first existing file from
-          /a/b/c/.be
-          /a/b/.be
-          /a/.be
-          /.be
-        or None if none of those files exist.
-        """
-        return search_parent_directories(path, filename)
-    def _use_rcs(self, path, allow_no_rcs):
-        """
-        Try and decide if _rcs_add/update/mkdir/etc calls will
-        succeed.  Returns True is we think the rcs_call would
-        succeeed, and False otherwise.
-        """
-        use_rcs = True
-        exception = None
-        if self.rootdir != None:
-            if self.path_in_root(path) == False:
-                use_rcs = False
-                exception = PathNotInRoot(path, self.rootdir)
-        else:
-            use_rcs = False
-            exception = RCSnotRooted
-        if use_rcs == False and allow_no_rcs==False:
-            raise exception
-        return use_rcs
-    def path_in_root(self, path, root=None):
-        """
-        Return the relative path to path from root.
-        >>> rcs = new()
-        >>> rcs.path_in_root("/a.b/c/.be", "/a.b/c")
-        True
-        >>> rcs.path_in_root("/a.b/.be", "/a.b/c")
-        False
-        """
-        if root == None:
-            if self.rootdir == None:
-                raise RCSnotRooted
-            root = self.rootdir
-        path = os.path.abspath(path)
-        absRoot = os.path.abspath(root)
-        absRootSlashedDir = os.path.join(absRoot,"")
-        if not path.startswith(absRootSlashedDir):
-            return False
-        return True
-    def _u_rel_path(self, path, root=None):
-        """
-        Return the relative path to path from root.
-        >>> rcs = new()
-        >>> rcs._u_rel_path("/a.b/c/.be", "/a.b/c")
-        '.be'
-        """
-        if root == None:
-            if self.rootdir == None:
-                raise RCSnotRooted
-            root = self.rootdir
-        path = os.path.abspath(path)
-        absRoot = os.path.abspath(root)
-        absRootSlashedDir = os.path.join(absRoot,"")
-        if not path.startswith(absRootSlashedDir):
-            raise PathNotInRoot(path, absRootSlashedDir)
-        assert path != absRootSlashedDir, \
-            "file %s == root directory %s" % (path, absRootSlashedDir)
-        relpath = path[len(absRootSlashedDir):]
-        return relpath
-    def _u_abspath(self, path, root=None):
-        """
-        Return the absolute path from a path realtive to root.
-        >>> rcs = new()
-        >>> rcs._u_abspath(".be", "/a.b/c")
-        '/a.b/c/.be'
-        """
-        if root == None:
-            assert self.rootdir != None, "RCS not rooted"
-            root = self.rootdir
-        return os.path.abspath(os.path.join(root, path))
-    def _u_create_id(self, name, email=None):
-        """
-        >>> rcs = new()
-        >>> rcs._u_create_id("John Doe", "jdoe@example.com")
-        'John Doe <jdoe@example.com>'
-        >>> rcs._u_create_id("John Doe")
-        'John Doe'
-        """
-        assert len(name) > 0
-        if email == None or len(email) == 0:
-            return name
-        else:
-            return "%s <%s>" % (name, email)
-    def _u_parse_id(self, value):
-        """
-        >>> rcs = new()
-        >>> rcs._u_parse_id("John Doe <jdoe@example.com>")
-        ('John Doe', 'jdoe@example.com')
-        >>> rcs._u_parse_id("John Doe")
-        ('John Doe', None)
-        >>> try:
-        ...     rcs._u_parse_id("John Doe <jdoe@example.com><what?>")
-        ... except AssertionError:
-        ...     print "Invalid match"
-        Invalid match
-        """
-        emailexp = re.compile("(.*) <([^>]*)>(.*)")
-        match = emailexp.search(value)
-        if match == None:
-            email = None
-            name = value
-        else:
-            assert len(match.groups()) == 3
-            assert match.groups()[2] == "", match.groups()
-            email = match.groups()[1]
-            name = match.groups()[0]
-        assert name != None
-        assert len(name) > 0
-        return (name, email)
-    def _u_get_fallback_username(self):
-        name = None
-        for envariable in ["LOGNAME", "USERNAME"]:
-            if os.environ.has_key(envariable):
-                name = os.environ[envariable]
-                break
-        assert name != None
-        return name
-    def _u_get_fallback_email(self):
-        hostname = gethostname()
-        name = self._u_get_fallback_username()
-        return "%s@%s" % (name, hostname)
-    def _u_parse_commitfile(self, commitfile):
-        """
-        Split the commitfile created in self.commit() back into
-        summary and header lines.
-        """
-        f = codecs.open(commitfile, "r", self.encoding)
-        summary = f.readline()
-        body = f.read()
-        body.lstrip('\n')
-        if len(body) == 0:
-            body = None
-        f.close()
-        return (summary, body)
-        
-\f
-def setup_rcs_test_fixtures(testcase):
-    """Set up test fixtures for RCS test case."""
-    testcase.rcs = testcase.Class()
-    testcase.dir = Dir()
-    testcase.dirname = testcase.dir.path
-
-    rcs_not_supporting_uninitialized_user_id = []
-    rcs_not_supporting_set_user_id = ["None", "hg"]
-    testcase.rcs_supports_uninitialized_user_id = (
-        testcase.rcs.name not in rcs_not_supporting_uninitialized_user_id)
-    testcase.rcs_supports_set_user_id = (
-        testcase.rcs.name not in rcs_not_supporting_set_user_id)
-
-    if not testcase.rcs.installed():
-        testcase.fail(
-            "%(name)s RCS not found" % vars(testcase.Class))
-
-    if testcase.Class.name != "None":
-        testcase.failIf(
-            testcase.rcs.detect(testcase.dirname),
-            "Detected %(name)s RCS before initialising"
-                % vars(testcase.Class))
-
-    testcase.rcs.init(testcase.dirname)
-
-
-class RCSTestCase(unittest.TestCase):
-    """Test cases for base RCS class."""
-
-    Class = RCS
-
-    def __init__(self, *args, **kwargs):
-        super(RCSTestCase, self).__init__(*args, **kwargs)
-        self.dirname = None
-
-    def setUp(self):
-        super(RCSTestCase, self).setUp()
-        setup_rcs_test_fixtures(self)
-
-    def tearDown(self):
-        del(self.rcs)
-        super(RCSTestCase, self).tearDown()
-
-    def full_path(self, rel_path):
-        return os.path.join(self.dirname, rel_path)
-
-
-class RCS_init_TestCase(RCSTestCase):
-    """Test cases for RCS.init method."""
-
-    def test_detect_should_succeed_after_init(self):
-        """Should detect RCS in directory after initialization."""
-        self.failUnless(
-            self.rcs.detect(self.dirname),
-            "Did not detect %(name)s RCS after initialising"
-                % vars(self.Class))
-
-    def test_rcs_rootdir_in_specified_root_path(self):
-        """RCS root directory should be in specified root path."""
-        rp = os.path.realpath(self.rcs.rootdir)
-        dp = os.path.realpath(self.dirname)
-        rcs_name = self.Class.name
-        self.failUnless(
-            dp == rp or rp == None,
-            "%(rcs_name)s RCS root in wrong dir (%(dp)s %(rp)s)" % vars())
-
-
-class RCS_get_user_id_TestCase(RCSTestCase):
-    """Test cases for RCS.get_user_id method."""
-
-    def test_gets_existing_user_id(self):
-        """Should get the existing user ID."""
-        if not self.rcs_supports_uninitialized_user_id:
-            return
-
-        user_id = self.rcs.get_user_id()
-        self.failUnless(
-            user_id is not None,
-            "unable to get a user id")
-
-
-class RCS_set_user_id_TestCase(RCSTestCase):
-    """Test cases for RCS.set_user_id method."""
-
-    def setUp(self):
-        super(RCS_set_user_id_TestCase, self).setUp()
-
-        if self.rcs_supports_uninitialized_user_id:
-            self.prev_user_id = self.rcs.get_user_id()
-        else:
-            self.prev_user_id = "Uninitialized identity <bogus@example.org>"
-
-        if self.rcs_supports_set_user_id:
-            self.test_new_user_id = "John Doe <jdoe@example.com>"
-            self.rcs.set_user_id(self.test_new_user_id)
-
-    def tearDown(self):
-        if self.rcs_supports_set_user_id:
-            self.rcs.set_user_id(self.prev_user_id)
-        super(RCS_set_user_id_TestCase, self).tearDown()
-
-    def test_raises_error_in_unsupported_vcs(self):
-        """Should raise an error in a VCS that doesn't support it."""
-        if self.rcs_supports_set_user_id:
-            return
-        self.assertRaises(
-            SettingIDnotSupported,
-            self.rcs.set_user_id, "foo")
-
-    def test_updates_user_id_in_supporting_rcs(self):
-        """Should update the user ID in an RCS that supports it."""
-        if not self.rcs_supports_set_user_id:
-            return
-        user_id = self.rcs.get_user_id()
-        self.failUnlessEqual(
-            self.test_new_user_id, user_id,
-            "user id not set correctly (expected %s, got %s)"
-                % (self.test_new_user_id, user_id))
-
-
-def setup_rcs_revision_test_fixtures(testcase):
-    """Set up revision test fixtures for RCS test case."""
-    testcase.test_dirs = ['a', 'a/b', 'c']
-    for path in testcase.test_dirs:
-        testcase.rcs.mkdir(testcase.full_path(path))
-
-    testcase.test_files = ['a/text', 'a/b/text']
-
-    testcase.test_contents = {
-        'rev_1': "Lorem ipsum",
-        'uncommitted': "dolor sit amet",
-        }
-
-
-class RCS_mkdir_TestCase(RCSTestCase):
-    """Test cases for RCS.mkdir method."""
-
-    def setUp(self):
-        super(RCS_mkdir_TestCase, self).setUp()
-        setup_rcs_revision_test_fixtures(self)
-
-    def tearDown(self):
-        for path in reversed(sorted(self.test_dirs)):
-            self.rcs.recursive_remove(self.full_path(path))
-        super(RCS_mkdir_TestCase, self).tearDown()
-
-    def test_mkdir_creates_directory(self):
-        """Should create specified directory in filesystem."""
-        for path in self.test_dirs:
-            full_path = self.full_path(path)
-            self.failUnless(
-                os.path.exists(full_path),
-                "path %(full_path)s does not exist" % vars())
-
-
-class RCS_commit_TestCase(RCSTestCase):
-    """Test cases for RCS.commit method."""
-
-    def setUp(self):
-        super(RCS_commit_TestCase, self).setUp()
-        setup_rcs_revision_test_fixtures(self)
-
-    def tearDown(self):
-        for path in reversed(sorted(self.test_dirs)):
-            self.rcs.recursive_remove(self.full_path(path))
-        super(RCS_commit_TestCase, self).tearDown()
-
-    def test_file_contents_as_specified(self):
-        """Should set file contents as specified."""
-        test_contents = self.test_contents['rev_1']
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(full_path, test_contents)
-            current_contents = self.rcs.get_file_contents(full_path)
-            self.failUnlessEqual(test_contents, current_contents)
-
-    def test_file_contents_as_committed(self):
-        """Should have file contents as specified after commit."""
-        test_contents = self.test_contents['rev_1']
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(full_path, test_contents)
-            revision = self.rcs.commit("Initial file contents.")
-            current_contents = self.rcs.get_file_contents(full_path)
-            self.failUnlessEqual(test_contents, current_contents)
-
-    def test_file_contents_as_set_when_uncommitted(self):
-        """Should set file contents as specified after commit."""
-        if not self.rcs.versioned:
-            return
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['rev_1'])
-            revision = self.rcs.commit("Initial file contents.")
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['uncommitted'])
-            current_contents = self.rcs.get_file_contents(full_path)
-            self.failUnlessEqual(
-                self.test_contents['uncommitted'], current_contents)
-
-    def test_revision_file_contents_as_committed(self):
-        """Should get file contents as committed to specified revision."""
-        if not self.rcs.versioned:
-            return
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['rev_1'])
-            revision = self.rcs.commit("Initial file contents.")
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['uncommitted'])
-            committed_contents = self.rcs.get_file_contents(
-                full_path, revision)
-            self.failUnlessEqual(
-                self.test_contents['rev_1'], committed_contents)
-
-
-class RCS_duplicate_repo_TestCase(RCSTestCase):
-    """Test cases for RCS.duplicate_repo method."""
-
-    def setUp(self):
-        super(RCS_duplicate_repo_TestCase, self).setUp()
-        setup_rcs_revision_test_fixtures(self)
-
-    def tearDown(self):
-        self.rcs.remove_duplicate_repo()
-        for path in reversed(sorted(self.test_dirs)):
-            self.rcs.recursive_remove(self.full_path(path))
-        super(RCS_duplicate_repo_TestCase, self).tearDown()
-
-    def test_revision_file_contents_as_committed(self):
-        """Should match file contents as committed to specified revision."""
-        if not self.rcs.versioned:
-            return
-        for path in self.test_files:
-            full_path = self.full_path(path)
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['rev_1'])
-            revision = self.rcs.commit("Commit current status")
-            self.rcs.set_file_contents(
-                full_path, self.test_contents['uncommitted'])
-            dup_repo_path = self.rcs.duplicate_repo(revision)
-            dup_file_path = os.path.join(dup_repo_path, path)
-            dup_file_contents = file(dup_file_path, 'rb').read()
-            self.failUnlessEqual(
-                self.test_contents['rev_1'], dup_file_contents)
-            self.rcs.remove_duplicate_repo()
-
-
-def make_rcs_testcase_subclasses(rcs_class, namespace):
-    """Make RCSTestCase subclasses for rcs_class in the namespace."""
-    rcs_testcase_classes = [
-        c for c in (
-            ob for ob in globals().values() if isinstance(ob, type))
-        if issubclass(c, RCSTestCase)]
-
-    for base_class in rcs_testcase_classes:
-        testcase_class_name = rcs_class.__name__ + base_class.__name__
-        testcase_class_bases = (base_class,)
-        testcase_class_dict = dict(base_class.__dict__)
-        testcase_class_dict['Class'] = rcs_class
-        testcase_class = type(
-            testcase_class_name, testcase_class_bases, testcase_class_dict)
-        setattr(namespace, testcase_class_name, testcase_class)
-
-
-unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
-suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index dde247fb65c399eac52c455fa82cfa0f1410ee2e..ceea9d551e831b0b4d8b19719bcd7fad3ce75c42 100644 (file)
@@ -148,7 +148,8 @@ def versioned_property(name, doc,
             checked = checked_property(allowed=allowed)
             fulldoc += "\n\nThe allowed values for this property are: %s." \
                        % (', '.join(allowed))
-        hooked      = change_hook_property(hook=change_hook, mutable=mutable)
+        hooked      = change_hook_property(hook=change_hook, mutable=mutable,
+                                           default=EMPTY)
         primed      = primed_property(primer=primer, initVal=UNPRIMED)
         settings    = settings_property(name=name, null=UNPRIMED)
         docp        = doc_property(doc=fulldoc)
@@ -385,30 +386,24 @@ class SavedSettingsObjectTests(unittest.TestCase):
         self.failUnless(SAVES == [], SAVES)
         self.failUnless(t._settings_loaded == True, t._settings_loaded)
         self.failUnless(t.list_type == None, t.list_type)
-        self.failUnless(SAVES == [
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'"
-                ], SAVES)
+        self.failUnless(SAVES == [], SAVES)
         self.failUnless(t.settings["List-type"]==EMPTY,t.settings["List-type"])
         t.list_type = []
         self.failUnless(t.settings["List-type"] == [], t.settings["List-type"])
         self.failUnless(SAVES == [
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'"
                 ], SAVES)
         t.list_type.append(5)
         self.failUnless(SAVES == [
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
                 ], SAVES)
         self.failUnless(t.settings["List-type"] == [5],t.settings["List-type"])
         self.failUnless(SAVES == [ # the append(5) has not yet been saved
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
                 ], SAVES)
         self.failUnless(t.list_type == [5], t.list_type) # <-get triggers saved
 
         self.failUnless(SAVES == [ # now the append(5) has been saved.
-                "'None' -> '<class 'libbe.settings_object.EMPTY'>'",
                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
                 "'[]' -> '[5]'"
                 ], SAVES)
index 45ae0855b47cd7a4ff50034f8a4ab79a156ad24a..06d09e55f7a6073ae84292c7c141ca1553941fcc 100644 (file)
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
+"""
+Define a traversable tree structure.
+"""
+
 import doctest
 
 class Tree(list):
diff --git a/libbe/upgrade.py b/libbe/upgrade.py
new file mode 100644 (file)
index 0000000..4123c72
--- /dev/null
@@ -0,0 +1,187 @@
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Handle conversion between the various on-disk images.
+"""
+
+import os, os.path
+import sys
+import doctest
+
+import encoding
+import mapfile
+import vcs
+
+# a list of all past versions
+BUGDIR_DISK_VERSIONS = ["Bugs Everywhere Tree 1 0",
+                        "Bugs Everywhere Directory v1.1",
+                        "Bugs Everywhere Directory v1.2"]
+
+# the current version
+BUGDIR_DISK_VERSION = BUGDIR_DISK_VERSIONS[-1]
+
+class Upgrader (object):
+    "Class for converting "
+    initial_version = None
+    final_version = None
+    def __init__(self, root):
+        self.root = root
+        # use the "None" VCS to ensure proper encoding/decoding and
+        # simplify path construction.
+        self.vcs = vcs.vcs_by_name("None")
+        self.vcs.root(self.root)
+        self.vcs.encoding = encoding.get_encoding()
+
+    def get_path(self, *args):
+        """
+        Return a path relative to .root.
+        """
+        dir = os.path.join(self.root, ".be")
+        if len(args) == 0:
+            return dir
+        assert args[0] in ["version", "settings", "bugs"], str(args)
+        return os.path.join(dir, *args)
+
+    def check_initial_version(self):
+        path = self.get_path("version")
+        version = self.vcs.get_file_contents(path).rstrip("\n")
+        assert version == self.initial_version, version
+
+    def set_version(self):
+        path = self.get_path("version")
+        self.vcs.set_file_contents(path, self.final_version+"\n")
+
+    def upgrade(self):
+        print >> sys.stderr, "upgrading bugdir from '%s' to '%s'" \
+            % (self.initial_version, self.final_version)
+        self.check_initial_version()
+        self.set_version()
+        self._upgrade()
+
+    def _upgrade(self):
+        raise NotImplementedError
+
+
+class Upgrade_1_0_to_1_1 (Upgrader):
+    initial_version = "Bugs Everywhere Tree 1 0"
+    final_version = "Bugs Everywhere Directory v1.1"
+    def _upgrade_mapfile(self, path):
+        contents = self.vcs.get_file_contents(path)
+        old_format = False
+        for line in contents.splitlines():
+            if len(line.split("=")) == 2:
+                old_format = True
+                break
+        if old_format == True:
+            # translate to YAML.
+            newlines = []
+            for line in contents.splitlines():
+                line = line.rstrip('\n')
+                if len(line) == 0:
+                    continue
+                fields = line.split("=")
+                if len(fields) == 2:
+                    key,value = fields
+                    newlines.append('%s: "%s"' % (key, value.replace('"','\\"')))
+                else:
+                    newlines.append(line)
+            contents = '\n'.join(newlines)
+            # load the YAML and save
+            map = mapfile.parse(contents)
+            mapfile.map_save(self.vcs, path, map)
+
+    def _upgrade(self):
+        """
+        Comment value field "From" -> "Author".
+        Homegrown mapfile -> YAML.
+        """
+        path = self.get_path("settings")
+        self._upgrade_mapfile(path)
+        for bug_uuid in os.listdir(self.get_path("bugs")):
+            path = self.get_path("bugs", bug_uuid, "values")
+            self._upgrade_mapfile(path)
+            c_path = ["bugs", bug_uuid, "comments"]
+            if not os.path.exists(self.get_path(*c_path)):
+                continue # no comments for this bug
+            for comment_uuid in os.listdir(self.get_path(*c_path)):
+                path_list = c_path + [comment_uuid, "values"]
+                path = self.get_path(*path_list)
+                self._upgrade_mapfile(path)
+                settings = mapfile.map_load(self.vcs, path)
+                if "From" in settings:
+                    settings["Author"] = settings.pop("From")
+                    mapfile.map_save(self.vcs, path, settings)
+
+
+class Upgrade_1_1_to_1_2 (Upgrader):
+    initial_version = "Bugs Everywhere Directory v1.1"
+    final_version = "Bugs Everywhere Directory v1.2"
+    def _upgrade(self):
+        """
+        BugDir settings field "rcs_name" -> "vcs_name".
+        """
+        path = self.get_path("settings")
+        settings = mapfile.map_load(self.vcs, path)
+        if "rcs_name" in settings:
+            settings["vcs_name"] = settings.pop("rcs_name")
+            mapfile.map_save(self.vcs, path, settings)
+
+
+upgraders = [Upgrade_1_0_to_1_1,
+             Upgrade_1_1_to_1_2]
+upgrade_classes = {}
+for upgrader in upgraders:
+    upgrade_classes[(upgrader.initial_version,upgrader.final_version)]=upgrader
+
+def upgrade(path, current_version,
+            target_version=BUGDIR_DISK_VERSION):
+    """
+    Call the appropriate upgrade function to convert current_version
+    to target_version.  If a direct conversion function does not exist,
+    use consecutive conversion functions.
+    """
+    if current_version not in BUGDIR_DISK_VERSIONS:
+        raise NotImplementedError, \
+            "Cannot handle version '%s' yet." % version
+    if target_version not in BUGDIR_DISK_VERSIONS:
+        raise NotImplementedError, \
+            "Cannot handle version '%s' yet." % version
+
+    if (current_version, target_version) in upgrade_classes:
+        # direct conversion
+        upgrade_class = upgrade_classes[(current_version, target_version)]
+        u = upgrade_class(path)
+        u.upgrade()
+    else:
+        # consecutive single-step conversion
+        i = BUGDIR_DISK_VERSIONS.index(current_version)
+        while True:
+            version_a = BUGDIR_DISK_VERSIONS[i]
+            version_b = BUGDIR_DISK_VERSIONS[i+1]
+            try:
+                upgrade_class = upgrade_classes[(version_a, version_b)]
+            except KeyError:
+                raise NotImplementedError, \
+                    "Cannot convert version '%s' to '%s' yet." \
+                    % (version_a, version_b)
+            u = upgrade_class(path)
+            u.upgrade()
+            if version_b == target_version:
+                break
+            i += 1
+
+suite = doctest.DocTestSuite()
index 3df06b40b7373c26e62bef2bce932f41c3631723..aafbf8d164c5d77b446114d28347299c450c9b38 100644 (file)
 # 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.
+
+"""
+Assorted utility functions that don't fit in anywhere else.
+"""
+
 import calendar
 import codecs
 import os
diff --git a/libbe/vcs.py b/libbe/vcs.py
new file mode 100644 (file)
index 0000000..a1d3022
--- /dev/null
@@ -0,0 +1,938 @@
+# Copyright (C) 2005-2009 Aaron Bentley and Panometrics, Inc.
+#                         Alexander Belchenko <bialix@ukr.net>
+#                         Ben Finney <ben+python@benfinney.id.au>
+#                         Chris Ball <cjb@laptop.org>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Define the base VCS (Version Control System) class, which should be
+subclassed by other Version Control System backends.  The base class
+implements a "do not version" VCS.
+"""
+
+from subprocess import Popen, PIPE
+import codecs
+import os
+import os.path
+import re
+from socket import gethostname
+import shutil
+import sys
+import tempfile
+import unittest
+import doctest
+
+from utility import Dir, search_parent_directories
+
+
+def _get_matching_vcs(matchfn):
+    """Return the first module for which matchfn(VCS_instance) is true"""
+    import arch
+    import bzr
+    import darcs
+    import git
+    import hg
+    for module in [arch, bzr, darcs, git, hg]:
+        vcs = module.new()
+        if matchfn(vcs) == True:
+            return vcs
+        del(vcs)
+    return VCS()
+    
+def vcs_by_name(vcs_name):
+    """Return the module for the VCS with the given name"""
+    return _get_matching_vcs(lambda vcs: vcs.name == vcs_name)
+
+def detect_vcs(dir):
+    """Return an VCS instance for the vcs being used in this directory"""
+    return _get_matching_vcs(lambda vcs: vcs.detect(dir))
+
+def installed_vcs():
+    """Return an instance of an installed VCS"""
+    return _get_matching_vcs(lambda vcs: vcs.installed())
+
+
+class CommandError(Exception):
+    def __init__(self, command, status, stdout, stderr):
+        strerror = ["Command failed (%d):\n  %s\n" % (status, stderr),
+                    "while executing\n  %s" % command]
+        Exception.__init__(self, "\n".join(strerror))
+        self.command = command
+        self.status = status
+        self.stdout = stdout
+        self.stderr = stderr
+
+class SettingIDnotSupported(NotImplementedError):
+    pass
+
+class VCSnotRooted(Exception):
+    def __init__(self):
+        msg = "VCS not rooted"
+        Exception.__init__(self, msg)
+
+class PathNotInRoot(Exception):
+    def __init__(self, path, root):
+        msg = "Path '%s' not in root '%s'" % (path, root)
+        Exception.__init__(self, msg)
+        self.path = path
+        self.root = root
+
+class NoSuchFile(Exception):
+    def __init__(self, pathname, root="."):
+        path = os.path.abspath(os.path.join(root, pathname))
+        Exception.__init__(self, "No such file: %s" % path)
+
+class EmptyCommit(Exception):
+    def __init__(self):
+        Exception.__init__(self, "No changes to commit")
+
+
+def new():
+    return VCS()
+
+class VCS(object):
+    """
+    This class implements a 'no-vcs' interface.
+
+    Support for other VCSs can be added by subclassing this class, and
+    overriding methods _vcs_*() with code appropriate for your VCS.
+    
+    The methods _u_*() are utility methods available to the _vcs_*()
+    methods.
+    """
+    name = "None"
+    client = "" # command-line tool for _u_invoke_client
+    versioned = False
+    def __init__(self, paranoid=False, encoding=sys.getdefaultencoding()):
+        self.paranoid = paranoid
+        self.verboseInvoke = False
+        self.rootdir = None
+        self._duplicateBasedir = None
+        self._duplicateDirname = None
+        self.encoding = encoding
+    def __del__(self):
+        self.cleanup()
+
+    def _vcs_help(self):
+        """
+        Return the command help string.
+        (Allows a simple test to see if the client is installed.)
+        """
+        pass
+    def _vcs_detect(self, path=None):
+        """
+        Detect whether a directory is revision controlled with this VCS.
+        """
+        return True
+    def _vcs_root(self, path):
+        """
+        Get the VCS root.  This is the default working directory for
+        future invocations.  You would normally set this to the root
+        directory for your VCS.
+        """
+        if os.path.isdir(path)==False:
+            path = os.path.dirname(path)
+            if path == "":
+                path = os.path.abspath(".")
+        return path
+    def _vcs_init(self, path):
+        """
+        Begin versioning the tree based at path.
+        """
+        pass
+    def _vcs_cleanup(self):
+        """
+        Remove any cruft that _vcs_init() created outside of the
+        versioned tree.
+        """
+        pass
+    def _vcs_get_user_id(self):
+        """
+        Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
+        If the VCS has not been configured with a username, return None.
+        """
+        return None
+    def _vcs_set_user_id(self, value):
+        """
+        Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>").
+        This is run if the VCS has not been configured with a usename, so
+        that commits will have a reasonable FROM value.
+        """
+        raise SettingIDnotSupported
+    def _vcs_add(self, path):
+        """
+        Add the already created file at path to version control.
+        """
+        pass
+    def _vcs_remove(self, path):
+        """
+        Remove the file at path from version control.  Optionally
+        remove the file from the filesystem as well.
+        """
+        pass
+    def _vcs_update(self, path):
+        """
+        Notify the versioning system of changes to the versioned file
+        at path.
+        """
+        pass
+    def _vcs_get_file_contents(self, path, revision=None, binary=False):
+        """
+        Get the file contents as they were in a given revision.
+        Revision==None specifies the current revision.
+        """
+        assert revision == None, \
+            "The %s VCS does not support revision specifiers" % self.name
+        if binary == False:
+            f = codecs.open(os.path.join(self.rootdir, path), "r", self.encoding)
+        else:
+            f = open(os.path.join(self.rootdir, path), "rb")
+        contents = f.read()
+        f.close()
+        return contents
+    def _vcs_duplicate_repo(self, directory, revision=None):
+        """
+        Get the repository as it was in a given revision.
+        revision==None specifies the current revision.
+        dir specifies a directory to create the duplicate in.
+        """
+        shutil.copytree(self.rootdir, directory, True)
+    def _vcs_commit(self, commitfile, allow_empty=False):
+        """
+        Commit the current working directory, using the contents of
+        commitfile as the comment.  Return the name of the old
+        revision (or None if commits are not supported).
+        
+        If allow_empty == False, raise EmptyCommit if there are no
+        changes to commit.
+        """
+        return None
+    def _vcs_revision_id(self, index):
+        """
+        Return the name of the <index>th revision.  Index will be an
+        integer (possibly <= 0).  The choice of which branch to follow
+        when crossing branches/merges is not defined.
+
+        Return None if revision IDs are not supported, or if the
+        specified revision does not exist.
+        """
+        return None
+    def installed(self):
+        try:
+            self._vcs_help()
+            return True
+        except OSError, e:
+            if e.errno == errno.ENOENT:
+                return False
+        except CommandError:
+            return False
+    def detect(self, path="."):
+        """
+        Detect whether a directory is revision controlled with this VCS.
+        """
+        return self._vcs_detect(path)
+    def root(self, path):
+        """
+        Set the root directory to the path's VCS root.  This is the
+        default working directory for future invocations.
+        """
+        self.rootdir = self._vcs_root(path)
+    def init(self, path):
+        """
+        Begin versioning the tree based at path.
+        Also roots the vcs at path.
+        """
+        if os.path.isdir(path)==False:
+            path = os.path.dirname(path)
+        self._vcs_init(path)
+        self.root(path)
+    def cleanup(self):
+        self._vcs_cleanup()
+    def get_user_id(self):
+        """
+        Get the VCS's suggested user id (e.g. "John Doe <jdoe@example.com>").
+        If the VCS has not been configured with a username, return the user's
+        id.  You can override the automatic lookup procedure by setting the
+        VCS.user_id attribute to a string of your choice.
+        """
+        if hasattr(self, "user_id"):
+            if self.user_id != None:
+                return self.user_id
+        id = self._vcs_get_user_id()
+        if id == None:
+            name = self._u_get_fallback_username()
+            email = self._u_get_fallback_email()
+            id = self._u_create_id(name, email)
+            print >> sys.stderr, "Guessing id '%s'" % id
+            try:
+                self.set_user_id(id)
+            except SettingIDnotSupported:
+                pass
+        return id
+    def set_user_id(self, value):
+        """
+        Set the VCS's suggested user id (e.g "John Doe <jdoe@example.com>").
+        This is run if the VCS has not been configured with a usename, so
+        that commits will have a reasonable FROM value.
+        """
+        self._vcs_set_user_id(value)
+    def add(self, path):
+        """
+        Add the already created file at path to version control.
+        """
+        self._vcs_add(self._u_rel_path(path))
+    def remove(self, path):
+        """
+        Remove a file from both version control and the filesystem.
+        """
+        self._vcs_remove(self._u_rel_path(path))
+        if os.path.exists(path):
+            os.remove(path)
+    def recursive_remove(self, dirname):
+        """
+        Remove a file/directory and all its decendents from both
+        version control and the filesystem.
+        """
+        if not os.path.exists(dirname):
+            raise NoSuchFile(dirname)
+        for dirpath,dirnames,filenames in os.walk(dirname, topdown=False):
+            filenames.extend(dirnames)
+            for path in filenames:
+                fullpath = os.path.join(dirpath, path)
+                if os.path.exists(fullpath) == False:
+                    continue
+                self._vcs_remove(self._u_rel_path(fullpath))
+        if os.path.exists(dirname):
+            shutil.rmtree(dirname)
+    def update(self, path):
+        """
+        Notify the versioning system of changes to the versioned file
+        at path.
+        """
+        self._vcs_update(self._u_rel_path(path))
+    def get_file_contents(self, path, revision=None, allow_no_vcs=False, binary=False):
+        """
+        Get the file as it was in a given revision.
+        Revision==None specifies the current revision.
+        """
+        if not os.path.exists(path):
+            raise NoSuchFile(path)
+        if self._use_vcs(path, allow_no_vcs):
+            relpath = self._u_rel_path(path)
+            contents = self._vcs_get_file_contents(relpath,revision,binary=binary)
+        else:
+            f = codecs.open(path, "r", self.encoding)
+            contents = f.read()
+            f.close()
+        return contents
+    def set_file_contents(self, path, contents, allow_no_vcs=False, binary=False):
+        """
+        Set the file contents under version control.
+        """
+        add = not os.path.exists(path)
+        if binary == False:
+            f = codecs.open(path, "w", self.encoding)
+        else:
+            f = open(path, "wb")
+        f.write(contents)
+        f.close()
+        
+        if self._use_vcs(path, allow_no_vcs):
+            if add:
+                self.add(path)
+            else:
+                self.update(path)
+    def mkdir(self, path, allow_no_vcs=False, check_parents=True):
+        """
+        Create (if neccessary) a directory at path under version
+        control.
+        """
+        if check_parents == True:
+            parent = os.path.dirname(path)
+            if not os.path.exists(parent): # recurse through parents
+                self.mkdir(parent, allow_no_vcs, check_parents)
+        if not os.path.exists(path):
+            os.mkdir(path)
+            if self._use_vcs(path, allow_no_vcs):
+                self.add(path)
+        else:
+            assert os.path.isdir(path)
+            if self._use_vcs(path, allow_no_vcs):
+                #self.update(path)# Don't update directories.  Changing files
+                pass              # underneath them should be sufficient.
+                
+    def duplicate_repo(self, revision=None):
+        """
+        Get the repository as it was in a given revision.
+        revision==None specifies the current revision.
+        Return the path to the arbitrary directory at the base of the new repo.
+        """
+        # Dirname in Baseir to protect against simlink attacks.
+        if self._duplicateBasedir == None:
+            self._duplicateBasedir = tempfile.mkdtemp(prefix='BEvcs')
+            self._duplicateDirname = \
+                os.path.join(self._duplicateBasedir, "duplicate")
+            self._vcs_duplicate_repo(directory=self._duplicateDirname,
+                                     revision=revision)
+        return self._duplicateDirname
+    def remove_duplicate_repo(self):
+        """
+        Clean up a duplicate repo created with duplicate_repo().
+        """
+        if self._duplicateBasedir != None:
+            shutil.rmtree(self._duplicateBasedir)
+            self._duplicateBasedir = None
+            self._duplicateDirname = None
+    def commit(self, summary, body=None, allow_empty=False):
+        """
+        Commit the current working directory, with a commit message
+        string summary and body.  Return the name of the old revision
+        (or None if versioning is not supported).
+        
+        If allow_empty == False (the default), raise EmptyCommit if
+        there are no changes to commit.
+        """
+        summary = summary.strip()+'\n'
+        if body is not None:
+            summary += '\n' + body.strip() + '\n'
+        descriptor, filename = tempfile.mkstemp()
+        revision = None
+        try:
+            temp_file = os.fdopen(descriptor, 'wb')
+            temp_file.write(summary)
+            temp_file.flush()
+            self.precommit()
+            revision = self._vcs_commit(filename, allow_empty=allow_empty)
+            temp_file.close()
+            self.postcommit()
+        finally:
+            os.remove(filename)
+        return revision
+    def precommit(self):
+        """
+        Executed before all attempted commits.
+        """
+        pass
+    def postcommit(self):
+        """
+        Only executed after successful commits.
+        """
+        pass
+    def revision_id(self, index=None):
+        """
+        Return the name of the <index>th revision.  The choice of
+        which branch to follow when crossing branches/merges is not
+        defined.
+
+        Return None if index==None, revision IDs are not supported, or
+        if the specified revision does not exist.
+        """
+        if index == None:
+            return None
+        return self._vcs_revision_id(index)
+    def _u_any_in_string(self, list, string):
+        """
+        Return True if any of the strings in list are in string.
+        Otherwise return False.
+        """
+        for list_string in list:
+            if list_string in string:
+                return True
+        return False
+    def _u_invoke(self, args, stdin=None, expect=(0,), cwd=None):
+        """
+        expect should be a tuple of allowed exit codes.  cwd should be
+        the directory from which the command will be executed.
+        """
+        if cwd == None:
+            cwd = self.rootdir
+        if self.verboseInvoke == True:
+            print >> sys.stderr, "%s$ %s" % (cwd, " ".join(args))
+        try :
+            if sys.platform != "win32":
+                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd)
+            else:
+                # win32 don't have os.execvp() so have to run command in a shell
+                q = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, 
+                          shell=True, cwd=cwd)
+        except OSError, e :
+            raise CommandError(args, status=e.args[0], stdout="", stderr=e)
+        output,error = q.communicate(input=stdin)
+        status = q.wait()
+        if self.verboseInvoke == True:
+            print >> sys.stderr, "%d\n%s%s" % (status, output, error)
+        if status not in expect:
+            raise CommandError(args, status, output, error)
+        return status, output, error
+    def _u_invoke_client(self, *args, **kwargs):
+        directory = kwargs.get('directory',None)
+        expect = kwargs.get('expect', (0,))
+        stdin = kwargs.get('stdin', None)
+        cl_args = [self.client]
+        cl_args.extend(args)
+        return self._u_invoke(cl_args, stdin=stdin,expect=expect,cwd=directory)
+    def _u_search_parent_directories(self, path, filename):
+        """
+        Find the file (or directory) named filename in path or in any
+        of path's parents.
+        
+        e.g.
+          search_parent_directories("/a/b/c", ".be")
+        will return the path to the first existing file from
+          /a/b/c/.be
+          /a/b/.be
+          /a/.be
+          /.be
+        or None if none of those files exist.
+        """
+        return search_parent_directories(path, filename)
+    def _use_vcs(self, path, allow_no_vcs):
+        """
+        Try and decide if _vcs_add/update/mkdir/etc calls will
+        succeed.  Returns True is we think the vcs_call would
+        succeeed, and False otherwise.
+        """
+        use_vcs = True
+        exception = None
+        if self.rootdir != None:
+            if self.path_in_root(path) == False:
+                use_vcs = False
+                exception = PathNotInRoot(path, self.rootdir)
+        else:
+            use_vcs = False
+            exception = VCSnotRooted
+        if use_vcs == False and allow_no_vcs==False:
+            raise exception
+        return use_vcs
+    def path_in_root(self, path, root=None):
+        """
+        Return the relative path to path from root.
+        >>> vcs = new()
+        >>> vcs.path_in_root("/a.b/c/.be", "/a.b/c")
+        True
+        >>> vcs.path_in_root("/a.b/.be", "/a.b/c")
+        False
+        """
+        if root == None:
+            if self.rootdir == None:
+                raise VCSnotRooted
+            root = self.rootdir
+        path = os.path.abspath(path)
+        absRoot = os.path.abspath(root)
+        absRootSlashedDir = os.path.join(absRoot,"")
+        if not path.startswith(absRootSlashedDir):
+            return False
+        return True
+    def _u_rel_path(self, path, root=None):
+        """
+        Return the relative path to path from root.
+        >>> vcs = new()
+        >>> vcs._u_rel_path("/a.b/c/.be", "/a.b/c")
+        '.be'
+        """
+        if root == None:
+            if self.rootdir == None:
+                raise VCSnotRooted
+            root = self.rootdir
+        path = os.path.abspath(path)
+        absRoot = os.path.abspath(root)
+        absRootSlashedDir = os.path.join(absRoot,"")
+        if not path.startswith(absRootSlashedDir):
+            raise PathNotInRoot(path, absRootSlashedDir)
+        assert path != absRootSlashedDir, \
+            "file %s == root directory %s" % (path, absRootSlashedDir)
+        relpath = path[len(absRootSlashedDir):]
+        return relpath
+    def _u_abspath(self, path, root=None):
+        """
+        Return the absolute path from a path realtive to root.
+        >>> vcs = new()
+        >>> vcs._u_abspath(".be", "/a.b/c")
+        '/a.b/c/.be'
+        """
+        if root == None:
+            assert self.rootdir != None, "VCS not rooted"
+            root = self.rootdir
+        return os.path.abspath(os.path.join(root, path))
+    def _u_create_id(self, name, email=None):
+        """
+        >>> vcs = new()
+        >>> vcs._u_create_id("John Doe", "jdoe@example.com")
+        'John Doe <jdoe@example.com>'
+        >>> vcs._u_create_id("John Doe")
+        'John Doe'
+        """
+        assert len(name) > 0
+        if email == None or len(email) == 0:
+            return name
+        else:
+            return "%s <%s>" % (name, email)
+    def _u_parse_id(self, value):
+        """
+        >>> vcs = new()
+        >>> vcs._u_parse_id("John Doe <jdoe@example.com>")
+        ('John Doe', 'jdoe@example.com')
+        >>> vcs._u_parse_id("John Doe")
+        ('John Doe', None)
+        >>> try:
+        ...     vcs._u_parse_id("John Doe <jdoe@example.com><what?>")
+        ... except AssertionError:
+        ...     print "Invalid match"
+        Invalid match
+        """
+        emailexp = re.compile("(.*) <([^>]*)>(.*)")
+        match = emailexp.search(value)
+        if match == None:
+            email = None
+            name = value
+        else:
+            assert len(match.groups()) == 3
+            assert match.groups()[2] == "", match.groups()
+            email = match.groups()[1]
+            name = match.groups()[0]
+        assert name != None
+        assert len(name) > 0
+        return (name, email)
+    def _u_get_fallback_username(self):
+        name = None
+        for envariable in ["LOGNAME", "USERNAME"]:
+            if os.environ.has_key(envariable):
+                name = os.environ[envariable]
+                break
+        assert name != None
+        return name
+    def _u_get_fallback_email(self):
+        hostname = gethostname()
+        name = self._u_get_fallback_username()
+        return "%s@%s" % (name, hostname)
+    def _u_parse_commitfile(self, commitfile):
+        """
+        Split the commitfile created in self.commit() back into
+        summary and header lines.
+        """
+        f = codecs.open(commitfile, "r", self.encoding)
+        summary = f.readline()
+        body = f.read()
+        body.lstrip('\n')
+        if len(body) == 0:
+            body = None
+        f.close()
+        return (summary, body)
+        
+\f
+def setup_vcs_test_fixtures(testcase):
+    """Set up test fixtures for VCS test case."""
+    testcase.vcs = testcase.Class()
+    testcase.dir = Dir()
+    testcase.dirname = testcase.dir.path
+
+    vcs_not_supporting_uninitialized_user_id = []
+    vcs_not_supporting_set_user_id = ["None", "hg"]
+    testcase.vcs_supports_uninitialized_user_id = (
+        testcase.vcs.name not in vcs_not_supporting_uninitialized_user_id)
+    testcase.vcs_supports_set_user_id = (
+        testcase.vcs.name not in vcs_not_supporting_set_user_id)
+
+    if not testcase.vcs.installed():
+        testcase.fail(
+            "%(name)s VCS not found" % vars(testcase.Class))
+
+    if testcase.Class.name != "None":
+        testcase.failIf(
+            testcase.vcs.detect(testcase.dirname),
+            "Detected %(name)s VCS before initialising"
+                % vars(testcase.Class))
+
+    testcase.vcs.init(testcase.dirname)
+
+
+class VCSTestCase(unittest.TestCase):
+    """Test cases for base VCS class."""
+
+    Class = VCS
+
+    def __init__(self, *args, **kwargs):
+        super(VCSTestCase, self).__init__(*args, **kwargs)
+        self.dirname = None
+
+    def setUp(self):
+        super(VCSTestCase, self).setUp()
+        setup_vcs_test_fixtures(self)
+
+    def tearDown(self):
+        del(self.vcs)
+        super(VCSTestCase, self).tearDown()
+
+    def full_path(self, rel_path):
+        return os.path.join(self.dirname, rel_path)
+
+
+class VCS_init_TestCase(VCSTestCase):
+    """Test cases for VCS.init method."""
+
+    def test_detect_should_succeed_after_init(self):
+        """Should detect VCS in directory after initialization."""
+        self.failUnless(
+            self.vcs.detect(self.dirname),
+            "Did not detect %(name)s VCS after initialising"
+                % vars(self.Class))
+
+    def test_vcs_rootdir_in_specified_root_path(self):
+        """VCS root directory should be in specified root path."""
+        rp = os.path.realpath(self.vcs.rootdir)
+        dp = os.path.realpath(self.dirname)
+        vcs_name = self.Class.name
+        self.failUnless(
+            dp == rp or rp == None,
+            "%(vcs_name)s VCS root in wrong dir (%(dp)s %(rp)s)" % vars())
+
+
+class VCS_get_user_id_TestCase(VCSTestCase):
+    """Test cases for VCS.get_user_id method."""
+
+    def test_gets_existing_user_id(self):
+        """Should get the existing user ID."""
+        if not self.vcs_supports_uninitialized_user_id:
+            return
+
+        user_id = self.vcs.get_user_id()
+        self.failUnless(
+            user_id is not None,
+            "unable to get a user id")
+
+
+class VCS_set_user_id_TestCase(VCSTestCase):
+    """Test cases for VCS.set_user_id method."""
+
+    def setUp(self):
+        super(VCS_set_user_id_TestCase, self).setUp()
+
+        if self.vcs_supports_uninitialized_user_id:
+            self.prev_user_id = self.vcs.get_user_id()
+        else:
+            self.prev_user_id = "Uninitialized identity <bogus@example.org>"
+
+        if self.vcs_supports_set_user_id:
+            self.test_new_user_id = "John Doe <jdoe@example.com>"
+            self.vcs.set_user_id(self.test_new_user_id)
+
+    def tearDown(self):
+        if self.vcs_supports_set_user_id:
+            self.vcs.set_user_id(self.prev_user_id)
+        super(VCS_set_user_id_TestCase, self).tearDown()
+
+    def test_raises_error_in_unsupported_vcs(self):
+        """Should raise an error in a VCS that doesn't support it."""
+        if self.vcs_supports_set_user_id:
+            return
+        self.assertRaises(
+            SettingIDnotSupported,
+            self.vcs.set_user_id, "foo")
+
+    def test_updates_user_id_in_supporting_vcs(self):
+        """Should update the user ID in an VCS that supports it."""
+        if not self.vcs_supports_set_user_id:
+            return
+        user_id = self.vcs.get_user_id()
+        self.failUnlessEqual(
+            self.test_new_user_id, user_id,
+            "user id not set correctly (expected %s, got %s)"
+                % (self.test_new_user_id, user_id))
+
+
+def setup_vcs_revision_test_fixtures(testcase):
+    """Set up revision test fixtures for VCS test case."""
+    testcase.test_dirs = ['a', 'a/b', 'c']
+    for path in testcase.test_dirs:
+        testcase.vcs.mkdir(testcase.full_path(path))
+
+    testcase.test_files = ['a/text', 'a/b/text']
+
+    testcase.test_contents = {
+        'rev_1': "Lorem ipsum",
+        'uncommitted': "dolor sit amet",
+        }
+
+
+class VCS_mkdir_TestCase(VCSTestCase):
+    """Test cases for VCS.mkdir method."""
+
+    def setUp(self):
+        super(VCS_mkdir_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_mkdir_TestCase, self).tearDown()
+
+    def test_mkdir_creates_directory(self):
+        """Should create specified directory in filesystem."""
+        for path in self.test_dirs:
+            full_path = self.full_path(path)
+            self.failUnless(
+                os.path.exists(full_path),
+                "path %(full_path)s does not exist" % vars())
+
+
+class VCS_commit_TestCase(VCSTestCase):
+    """Test cases for VCS.commit method."""
+
+    def setUp(self):
+        super(VCS_commit_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_commit_TestCase, self).tearDown()
+
+    def test_file_contents_as_specified(self):
+        """Should set file contents as specified."""
+        test_contents = self.test_contents['rev_1']
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(full_path, test_contents)
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(test_contents, current_contents)
+
+    def test_file_contents_as_committed(self):
+        """Should have file contents as specified after commit."""
+        test_contents = self.test_contents['rev_1']
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(full_path, test_contents)
+            revision = self.vcs.commit("Initial file contents.")
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(test_contents, current_contents)
+
+    def test_file_contents_as_set_when_uncommitted(self):
+        """Should set file contents as specified after commit."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial file contents.")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            current_contents = self.vcs.get_file_contents(full_path)
+            self.failUnlessEqual(
+                self.test_contents['uncommitted'], current_contents)
+
+    def test_revision_file_contents_as_committed(self):
+        """Should get file contents as committed to specified revision."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial file contents.")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            committed_contents = self.vcs.get_file_contents(
+                full_path, revision)
+            self.failUnlessEqual(
+                self.test_contents['rev_1'], committed_contents)
+
+    def test_revision_id_as_committed(self):
+        """Check for compatibility between .commit() and .revision_id()"""
+        if not self.vcs.versioned:
+            self.failUnlessEqual(self.vcs.revision_id(5), None)
+            return
+        committed_revisions = []
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Initial %s contents." % path)
+            committed_revisions.append(revision)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            revision = self.vcs.commit("Altered %s contents." % path)
+            committed_revisions.append(revision)
+        for i,revision in enumerate(committed_revisions):
+            self.failUnlessEqual(self.vcs.revision_id(i), revision)
+            i += -len(committed_revisions) # check negative indices
+            self.failUnlessEqual(self.vcs.revision_id(i), revision)
+        i = len(committed_revisions)
+        self.failUnlessEqual(self.vcs.revision_id(i), None)
+        self.failUnlessEqual(self.vcs.revision_id(-i-1), None)
+
+    def test_revision_id_as_committed(self):
+        """Check revision id before first commit"""
+        if not self.vcs.versioned:
+            self.failUnlessEqual(self.vcs.revision_id(5), None)
+            return
+        committed_revisions = []
+        for path in self.test_files:
+            self.failUnlessEqual(self.vcs.revision_id(0), None)
+
+
+class VCS_duplicate_repo_TestCase(VCSTestCase):
+    """Test cases for VCS.duplicate_repo method."""
+
+    def setUp(self):
+        super(VCS_duplicate_repo_TestCase, self).setUp()
+        setup_vcs_revision_test_fixtures(self)
+
+    def tearDown(self):
+        self.vcs.remove_duplicate_repo()
+        for path in reversed(sorted(self.test_dirs)):
+            self.vcs.recursive_remove(self.full_path(path))
+        super(VCS_duplicate_repo_TestCase, self).tearDown()
+
+    def test_revision_file_contents_as_committed(self):
+        """Should match file contents as committed to specified revision."""
+        if not self.vcs.versioned:
+            return
+        for path in self.test_files:
+            full_path = self.full_path(path)
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['rev_1'])
+            revision = self.vcs.commit("Commit current status")
+            self.vcs.set_file_contents(
+                full_path, self.test_contents['uncommitted'])
+            dup_repo_path = self.vcs.duplicate_repo(revision)
+            dup_file_path = os.path.join(dup_repo_path, path)
+            dup_file_contents = file(dup_file_path, 'rb').read()
+            self.failUnlessEqual(
+                self.test_contents['rev_1'], dup_file_contents)
+            self.vcs.remove_duplicate_repo()
+
+
+def make_vcs_testcase_subclasses(vcs_class, namespace):
+    """Make VCSTestCase subclasses for vcs_class in the namespace."""
+    vcs_testcase_classes = [
+        c for c in (
+            ob for ob in globals().values() if isinstance(ob, type))
+        if issubclass(c, VCSTestCase)]
+
+    for base_class in vcs_testcase_classes:
+        testcase_class_name = vcs_class.__name__ + base_class.__name__
+        testcase_class_bases = (base_class,)
+        testcase_class_dict = dict(base_class.__dict__)
+        testcase_class_dict['Class'] = vcs_class
+        testcase_class = type(
+            testcase_class_name, testcase_class_bases, testcase_class_dict)
+        setattr(namespace, testcase_class_name, testcase_class)
+
+
+unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
diff --git a/libbe/version.py b/libbe/version.py
new file mode 100644 (file)
index 0000000..f8eebbd
--- /dev/null
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+# Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
+#
+# 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.
+
+"""
+Store version info for this BE installation.  By default, use the
+bzr-generated information in _version.py, but allow manual overriding
+by setting _VERSION.  This allows support of both the "I don't want to
+be bothered setting version strings" and the "I want complete control
+over the version strings" workflows.
+"""
+
+import libbe._version as _version
+
+# Manually set a version string (optional, defaults to bzr revision id)
+#_VERSION = "1.2.3"
+
+def version(verbose=False):
+    """
+    Returns the version string for this BE installation.  If
+    verbose==True, the string will include extra lines with more
+    detail (e.g. bzr branch nickname, etc.).
+    """
+    if "_VERSION" in globals():
+        string = _VERSION
+    else:
+        string = _version.version_info["revision_id"]
+    if verbose == True:
+        string += ("\n"
+                   "revision: %(revno)d\n"
+                   "nick: %(branch_nick)s\n"
+                   "revision id: %(revision_id)s"
+                   % _version.version_info)
+    return string
+
+if __name__ == "__main__":
+    print version(verbose=True)
index 28eb0e00fe8d0bea8d2c573b4ce1151969620eeb..84a591349d1771b1ba9a45c91fe5cd7f80dd94fe 100755 (executable)
@@ -59,7 +59,7 @@ ESCAPED_TEXT=`echo "$COPYRIGHT_TEXT" | awk '{printf("%s\\\\n", $0)}' | sed "$SED
 
 # adjust the AUTHORS file
 AUTHORS=`bzr log | grep '^ *committer\|^ *author' | cut -d: -f2 | sed 's/ <.*//;s/^ *//' | sort | uniq`
-AUTHORS=`echo "$AUTHORS" | grep -v 'j\^\|wking\|John Doe'` # remove non-names
+AUTHORS=`echo "$AUTHORS" | grep -v 'j\^\|wking\|John Doe\|gianluca'` # remove non-names
 echo "Bugs Everywhere was written by:" > AUTHORS
 echo "$AUTHORS" >> AUTHORS
 
@@ -99,6 +99,12 @@ do
     # Tweak the author list to make up for problems in the bzr
     # history, change of email address, etc.
     
+    # Consolidate Chris Ball
+    GREP_OUT=`echo "$AUTHORS" | grep 'Chris Ball <cjb@laptop.org>'`
+    if [ -n "$GREP_OUT" ]; then
+       AUTHORS=`echo "$AUTHORS" | grep -v '^Chris Ball <cjb@thunk.printf.net>$'`
+    fi
+
     # Consolidate Aaron Bentley
     AUTHORS=`echo "$AUTHORS" | sed 's/<abentley@panoramicfeedback.com>/and Panometrics, Inc./'`
     GREP_OUT=`echo "$AUTHORS" | grep 'Aaron Bentley and Panometrics, Inc.'`
@@ -113,15 +119,12 @@ do
        AUTHORS=`echo "$AUTHORS" | grep -v '^Ben Finney <benf@cybersource.com.au>$'`
     fi
 
-    # Consolidate Chris Ball
-    GREP_OUT=`echo "$AUTHORS" | grep 'Chris Ball <cjb@laptop.org>'`
-    if [ -n "$GREP_OUT" ]; then
-       AUTHORS=`echo "$AUTHORS" | grep -v '^Chris Ball <cjb@thunk.printf.net>$'`
-    fi
-
     # Consolidate Trevor King
     AUTHORS=`echo "$AUTHORS" | grep -v "wking <wking@mjolnir>"`
 
+    # Consolidate Gianluca Montecchi
+    AUTHORS=`echo "$AUTHORS" | grep -v "gianluca"`
+
     # Sort again...
     AUTHORS=`echo "$AUTHORS" | sort | uniq`