--- /dev/null
+*.pyc
+build
--- /dev/null
+[project]
+name: pygrader
+vcs: Git
+
+[files]
+authors: yes
+files: yes
+ignored: COPYING, README, .update-copyright.conf, .git*, *.pyc
+
+[copyright]
+short: %(project)s comes with ABSOLUTELY NO WARRANTY and is licensed under the GNU General Public License.
+long: This file is part of %(project)s.
+
+ %(project)s 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 3 of the License, or (at your option) any later version.
+
+ %(project)s 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 %(project)s. If not, see <http://www.gnu.org/licenses/>.
--- /dev/null
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ 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 3 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, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
--- /dev/null
+``pygrader`` is a directory-based grade database for grading course
+assignments. Besides tracking grades locally, you can also use it to
+automatically mail grades to students and professors associated with
+the course. For secure communication, PGP_ can be used to sign and/or
+encrypt any of these emails.
+
+Installation
+============
+
+Packages
+--------
+
+Gentoo
+~~~~~~
+
+I've packaged ``pygrader`` for Gentoo_. You need layman_ and
+my `wtk overlay`_. Install with::
+
+ # emerge -av app-portage/layman
+ # layman --add wtk
+ # emerge -av dev-python/pygrader
+
+Dependencies
+------------
+
+``pygrader`` is a simple package. The only external dependency
+outside the Python 3 standard library is my `pgp-mime`_ package.
+
+Installing by hand
+------------------
+
+``pygrader`` is available as a Git_ repository::
+
+ $ git clone git://tremily.us/pygrader.git
+
+See the homepage_ for details. To install the checkout, run the
+standard::
+
+ $ python setup.py install
+
+Usage
+=====
+
+Pygrader will help keep you organized in a course where the students
+submit homework via email, or the homework submissions are otherwise
+digital (i.e. scanned in after submission). There is currently no
+support for multiple graders, although I will likely add this in the
+future. In the following sections, I'll walk you through
+administering the homework for the ``test`` course.
+
+All of the processing involves using the ``pg.py`` command. Run::
+
+ $ pg.py --help
+
+for details.
+
+Sending email
+-------------
+
+Pygrader receives submissions and assigns grades via email. In order
+to send email, it needs to connect to an SMTP_ server. See the
+pgp-mime documentation for details on configuring you SMTP connection.
+You can test your SMTP configuration by sending yourself a test
+message::
+
+ $ pg.py -VVV smtp -a rincewind@uu.edu -t rincewind@uu.edu
+
+Defining the course
+-------------------
+
+Once you've got email submission working, you need to configure the
+course you'll be grading. Each course lives in its own directory, and
+the basic setup looks like the ``test`` example distributed with
+pygrader. The file that you need to get started is the config file in
+the course directory::
+
+ $ cat test/course.conf
+ [course]
+ assignments: Attendance 1, Attendance 2, Attendance 3, Attendance 4,
+ Attendance 5, Attendance 6, Attendance 7, Attendance 8, Attendance 9,
+ Assignment 1, Assignment 2, Exam 1, Exam 2
+ professors: Gandalf
+ assistants: Sauron
+ students: Bilbo Baggins, Frodo Baggins, Aragorn
+
+ [Attendance 1]
+ points: 1
+ weight: 0.1/9
+ due: 2011-10-03
+
+ [Attendance 2]
+ points: 1
+ weight: 0.1/9
+ due: 2011-10-04
+
+ …
+
+ [Exam 2]
+ points: 10
+ weight: 0.4/2
+ due: 2011-10-17
+
+ [Gandalf]
+ nickname: G-Man
+ emails: g@grey.edu
+ pgp-key: 4332B6E3
+
+ [Sauron]
+ emails: eye@tower.edu
+
+ [Bilbo Baggins]
+ nickname: Bill
+ emails: bb@shire.org, bb@greyhavens.net
+
+ …
+
+The format is a bit wordy, but it is also explicit and easily
+extensible. The time it takes to construct this configuration file
+should be a small portion of the time you will spend grading
+submissions.
+
+If a person has the ``pgp-key`` option set, that key will be used to
+encrypt messages to that person and sign messages from that person
+with PGP_. It will also be used to authenticate ownership of incoming
+emails. You'll need to have GnuPG_ on your local host for this to
+work, and the user running pygrader should have the associated keys in
+their keychain. The ``pgp-fingerprint`` option is used when verifying
+that signed emails are signed by the appropriate person. You can
+extract the fingerprint for the PGP key using GnuPG::
+
+ $ gpg --fingerprint 4332B6E3
+ pub 2048R/4332B6E3 2012-03-21
+ Key fingerprint = B2ED BE0E 771A 4B87 08DD 16A7 511A EDA6 4332 B6E3
+ uid pgp-mime-test (http://blog.tremily.us/posts/pgp-mime/) <pgp-mime@invalid.com>
+
+Processing submissions
+----------------------
+
+As the due date approaches, student submissions will start arriving in
+your inbox. Use ``pg.py``'s ``mailpipe`` command to sort them into
+directories. This will also extract any files that were attached to
+the emails and place them in that persons assignment directory::
+
+ $ pg.py -d test mailpipe -m maildir -i ~/.maildir -o ./mail-old
+
+Use ``pg.py``'s ``todo`` command to check for ungraded submissions::
+
+ $ pg.py -d test todo mail grade
+
+To see how everyone's doing, you can print a table of grades with
+``pg.py``'s ``tabulate`` command::
+
+ $ pg.py -d test tabulate -s
+
+When you want to notify students of their grades, you can send them
+all out with ``pg.py``'s ``email`` command::
+
+ $ pg.py -d test email assignment 'Exam 1'
+
+Testing
+=======
+
+Run the internal unit tests using nose_::
+
+ $ nosetests --with-doctest --doctest-tests pygrader
+
+If a Python-3-version of ``nosetests`` is not the default on your
+system, you may need to try something like::
+
+ $ nosetests-3.2 --with-doctest --doctest-tests pygrader
+
+Licence
+=======
+
+This project is distributed under the `GNU General Public License
+Version 3`_ or greater.
+
+Author
+======
+
+W. Trevor King
+wking@drexel.edu
+
+Related work
+============
+
+For a similar project, see `Alex Heitzmann pygrade`_, which keeps the
+grade history in a single log file and provides more support for using
+graphical interfaces.
+
+
+.. _PGP: http://en.wikipedia.org/wiki/Pretty_Good_Privacy
+.. _Gentoo: http://www.gentoo.org/
+.. _layman: http://layman.sourceforge.net/
+.. _wtk overlay: http://blog.tremily.us/posts/Gentoo_overlay/
+.. _pgp-mime: http://blog.tremily.us/posts/pgp-mime/
+.. _Git: http://git-scm.com/
+.. _homepage: http://blog.tremily.us/posts/pygrader/
+.. _SMTP: http://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol
+.. _GnuPG: http://www.gnupg.org/
+.. _nose: http://readthedocs.org/docs/nose/en/latest/
+.. _GNU General Public License Version 3: http://www.gnu.org/licenses/gpl.html
+.. _Alex Heitzmann's pygrade: http://code.google.com/p/pygrade/
--- /dev/null
+#!/usr/bin/env python3
+#
+# Copyright
+
+"""Manage grades from the command line
+"""
+
+import configparser as _configparser
+from email.mime.text import MIMEText as _MIMEText
+import email.utils as _email_utils
+import inspect as _inspect
+import logging as _logging
+import os.path as _os_path
+import sys as _sys
+
+import pgp_mime as _pgp_mime
+
+from pygrader import __version__
+from pygrader import LOG as _LOG
+from pygrader.email import test_smtp as _test_smtp
+from pygrader.mailpipe import mailpipe as _mailpipe
+from pygrader.storage import initialize as _initialize
+from pygrader.storage import load_course as _load_course
+from pygrader.tabulate import tabulate as _tabulate
+from pygrader.template import assignment_email as _assignment_email
+from pygrader.template import course_email as _course_email
+from pygrader.template import student_email as _student_email
+from pygrader.todo import print_todo as _todo
+
+
+if __name__ == '__main__':
+ from argparse import ArgumentParser as _ArgumentParser
+
+ parser = _ArgumentParser(
+ description=__doc__, version=__version__)
+ parser.add_argument(
+ '-d', '--base-dir', dest='basedir', default='.',
+ help='Base directory containing grade data')
+ parser.add_argument(
+ '-c', '--color', default=False, action='store_const', const=True,
+ help='Color printed output with ANSI escape sequences')
+ parser.add_argument(
+ '-V', '--verbose', default=0, action='count',
+ help='Increase verbosity')
+ subparsers = parser.add_subparsers(title='commands')
+
+ smtp_parser = subparsers.add_parser(
+ 'smtp', help=_test_smtp.__doc__.splitlines()[0])
+ smtp_parser.set_defaults(func=_test_smtp)
+ smtp_parser.add_argument(
+ '-a', '--author',
+ help='Your address (email author)')
+ smtp_parser.add_argument(
+ '-t', '--target', dest='targets', action='append',
+ help='Address for the email recipient')
+
+ initialize_parser = subparsers.add_parser(
+ 'initialize', help=_initialize.__doc__.splitlines()[0])
+ initialize_parser.set_defaults(func=_initialize)
+ initialize_parser.add_argument(
+ '-D', '--dry-run', default=False, action='store_const', const=True,
+ help="Don't actually send emails, create files, etc.")
+
+ tabulate_parser = subparsers.add_parser(
+ 'tabulate', help=_tabulate.__doc__.splitlines()[0])
+ tabulate_parser.set_defaults(func=_tabulate)
+ tabulate_parser.add_argument(
+ '-s', '--statistics', default=False, action='store_const', const=True,
+ help='Calculate mean and standard deviation for each assignment')
+
+ email_parser = subparsers.add_parser(
+ 'email', help='Send emails containing grade information')
+ email_parser.add_argument(
+ '-D', '--dry-run', default=False, action='store_const', const=True,
+ help="Don't actually send emails, create files, etc.")
+ email_parser.add_argument(
+ '-a', '--author', default='Trevor King',
+ help='Your name (email author)')
+ email_parser.add_argument(
+ '--cc', action='append', help='People to carbon copy')
+ email_subparsers = email_parser.add_subparsers(title='type')
+ assignment_parser = email_subparsers.add_parser(
+ 'assignment', help=_assignment_email.__doc__.splitlines()[0])
+ assignment_parser.set_defaults(func=_assignment_email)
+ assignment_parser.add_argument(
+ 'assignment', help='Name of the target assignment')
+ student_parser = email_subparsers.add_parser(
+ 'student', help=_student_email.__doc__.splitlines()[0])
+ student_parser.set_defaults(func=_student_email)
+ student_parser.add_argument(
+ '-o', '--old', default=False, action='store_const', const=True,
+ help='Include already-notified information in emails')
+ student_parser.add_argument(
+ '-s', '--student', dest='student',
+ help='Explicitly select the student to notify (instead of everyone)')
+ course_parser = email_subparsers.add_parser(
+ 'course', help=_course_email.__doc__.splitlines()[0])
+ course_parser.set_defaults(func=_course_email)
+ course_parser.add_argument(
+ '-t', '--target', dest='targets', action='append',
+ help='Name, alias, or group for the email recipient(s)')
+
+ mailpipe_parser = subparsers.add_parser(
+ 'mailpipe', help=_mailpipe.__doc__.splitlines()[0])
+ mailpipe_parser.set_defaults(func=_mailpipe)
+ mailpipe_parser.add_argument(
+ '-D', '--dry-run', default=False, action='store_const', const=True,
+ help="Don't actually send emails, create files, etc.")
+ mailpipe_parser.add_argument(
+ '-m', '--mailbox', choices=['maildir', 'mbox'],
+ help=('Instead of piping a message in via stdout, you can also read '
+ 'directly from a mailbox. This option specifies the format of '
+ 'your target mailbox.'))
+ mailpipe_parser.add_argument(
+ '-i', '--input', dest='input_', metavar='INPUT',
+ help='Path to the mailbox containing messages to be processed')
+ mailpipe_parser.add_argument(
+ '-o', '--output',
+ help=('Path to the mailbox that will recieve successfully processed '
+ 'messages. If not given, successfully processed messages will '
+ 'be left in the input mailbox'))
+ mailpipe_parser.add_argument(
+ '-l', '--max-late', default=0, type=float,
+ help=('Grace period in seconds before an incoming assignment is '
+ 'actually marked as late'))
+
+ todo_parser = subparsers.add_parser(
+ 'todo', help=_todo.__doc__.splitlines()[0])
+ todo_parser.set_defaults(func=_todo)
+ todo_parser.add_argument(
+ 'source', help='Name of source file/directory')
+ todo_parser.add_argument(
+ 'target', help='Name of target file/directory')
+
+
+# p.add_option('-t', '--template', default=None)
+
+ args = parser.parse_args()
+
+ if args.verbose:
+ _LOG.setLevel(max(_logging.DEBUG, _LOG.level - 10*args.verbose))
+ _pgp_mime.LOG.setLevel(_LOG.level)
+
+ config = _configparser.ConfigParser()
+ config.read([
+ _os_path.expanduser(_os_path.join('~', '.config', 'smtplib.conf')),
+ ])
+
+ func_args = _inspect.getargspec(args.func).args
+ kwargs = {}
+
+ if 'basedir' in func_args:
+ kwargs['basedir'] = args.basedir
+
+ if 'course' in func_args:
+ course = _load_course(basedir=args.basedir)
+ active_groups = course.active_groups()
+ kwargs['course'] = course
+ if hasattr(args, 'assignment'):
+ kwargs['assignment'] = course.assignment(name=args.assignment)
+ if hasattr(args, 'cc') and args.cc:
+ kwargs['cc'] = [course.person(name=cc) for cc in args.cc]
+ for attr in ['author', 'student']:
+ if hasattr(args, attr):
+ name = getattr(args, attr)
+ kwargs[attr] = course.person(name=name)
+ for attr in ['targets']:
+ if hasattr(args, attr):
+ people = getattr(args, attr)
+ if people is None:
+ people = ['professors'] # for the course email
+ kwargs[attr] = []
+ for person in people:
+ if person in active_groups:
+ kwargs[attr].extend(course.find_people(group=person))
+ else:
+ kwargs[attr].extend(course.find_people(name=person))
+ for attr in ['dry_run', 'mailbox', 'output', 'input_', 'max_late',
+ 'old', 'statistics']:
+ if hasattr(args, attr):
+ kwargs[attr] = getattr(args, attr)
+ elif args.func == _test_smtp:
+ for attr in ['author', 'targets']:
+ if hasattr(args, attr):
+ kwargs[attr] = getattr(args, attr)
+ elif args.func == _todo:
+ for attr in ['source', 'target']:
+ if hasattr(args, attr):
+ kwargs[attr] = getattr(args, attr)
+
+ if 'use_color' in func_args:
+ kwargs['use_color'] = args.color
+
+ if ('smtp' in func_args and
+ not kwargs.get('dry_run', False) and
+ 'smtp' in config.sections()):
+ params = _pgp_mime.get_smtp_params(config)
+ kwargs['smtp'] = _pgp_mime.get_smtp(*params)
+ del params
+
+ _LOG.debug('execute {} with {}'.format(args.func, kwargs))
+ try:
+ ret = args.func(**kwargs)
+ finally:
+ smtp = kwargs.get('smtp', None)
+ if smtp:
+ _LOG.info('disconnect from SMTP server')
+ smtp.quit()
+ if ret is None:
+ ret = 0
+ _sys.exit(ret)
--- /dev/null
+# Copyright
+
+import logging as _logging
+
+__version__ = '0.1'
+ENCODING = 'utf-8'
+
+
+LOG = _logging.getLogger('pygrade')
+LOG.setLevel(_logging.ERROR)
+LOG.addHandler(_logging.StreamHandler())
--- /dev/null
+# Copyright
+
+import sys as _sys
+
+
+# Define ANSI escape sequences for colors
+_COLORS = [
+ 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
+
+USE_COLOR = True
+
+
+def standard_colors(use_color=None):
+ """Return a list of standard colors
+
+ >>> highlight,lowlight,good,bad = standard_colors()
+ >>> (highlight,lowlight,good,bad)
+ (None, 'blue', 'green', 'red')
+ """
+ if use_color is None:
+ use_color = USE_COLOR
+ if use_color:
+ highlight = None
+ lowlight = 'blue'
+ good = 'green'
+ bad = 'red'
+ else:
+ highlight = lowlight = good = bad = None
+ return (highlight, lowlight, good, bad)
+
+def _ansi_color_code(color):
+ r"""Return the appropriate ANSI escape sequence for `color`
+
+ >>> _ansi_color_code('blue')
+ '\x1b[34m'
+ >>> _ansi_color_code(None)
+ '\x1b[0m'
+ """
+ if color is None:
+ return '\033[0m'
+ return '\033[3%dm' % (_COLORS.index(color))
+
+def color_string(string, color=None):
+ r"""Wrap a string in ANSI escape sequences for coloring
+
+ >>> color_string('Hello world', 'red')
+ '\x1b[31mHello world\x1b[0m'
+ >>> color_string('Hello world', None)
+ 'Hello world'
+
+ It also works with non-unicode input:
+
+ >>> color_string('Hello world', 'red')
+ '\x1b[31mHello world\x1b[0m'
+ """
+ ret = []
+ if color:
+ ret.append(_ansi_color_code(color))
+ ret.append(string)
+ if color:
+ ret.append(_ansi_color_code(None))
+ sep = ''
+ if isinstance(string, str): # i.e., not unicode
+ ret = [str(x) for x in ret]
+ sep = ''
+ return sep.join(ret)
+
+def write_color(string, color=None, stream=None):
+ r"""Write a colored `string` to `stream`
+
+ If `stream` is `None`, it defaults to stdout.
+
+ >>> write_color('Hello world\n')
+ Hello world
+
+ >>> from io import StringIO
+ >>> stream = StringIO()
+ >>> write_color('Hello world\n', 'red', stream)
+ >>> stream.getvalue()
+ '\x1b[31mHello world\n\x1b[0m'
+ """
+ if stream is None:
+ stream = _sys.stdout
+ stream.write(color_string(string=string, color=color))
+ stream.flush()
--- /dev/null
+# -*- coding: utf-8 -*-
+# Copyright (C) 2011 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 __future__ import absolute_import
+
+from email.header import Header as _Header
+from email.header import decode_header as _decode_header
+import email.utils as _email_utils
+import logging as _logging
+import smtplib as _smtplib
+
+import pgp_mime as _pgp_mime
+
+from . import ENCODING as _ENCODING
+from . import LOG as _LOG
+from .color import standard_colors as _standard_colors
+from .color import color_string as _color_string
+from .color import write_color as _write_color
+from .model.person import Person as _Person
+
+
+def test_smtp(smtp, author, targets, msg=None):
+ """Test the SMTP connection by sending a message to `target`
+ """
+ if msg is None:
+ msg = _pgp_mime.encodedMIMEText('Success!')
+ msg['Date'] = _email_utils.formatdate()
+ msg['From'] = author
+ msg['Reply-to'] = msg['From']
+ msg['To'] = ', '.join(targets)
+ msg['Subject'] = 'Testing pygrader SMTP connection'
+ _LOG.info('send test message to SMTP server')
+ smtp.send_message(msg=msg)
+test_smtp.__test__ = False # not a test for nose
+
+def send_emails(emails, smtp=None, use_color=None, debug_target=None,
+ dry_run=False):
+ """Iterate through `emails` and mail them off one-by-one
+
+ >>> from email.mime.text import MIMEText
+ >>> from sys import stdout
+ >>> emails = []
+ >>> for target in ['Moneypenny <mp@sis.gov.uk>', 'M <m@sis.gov.uk>']:
+ ... msg = MIMEText('howdy!', 'plain', 'us-ascii')
+ ... msg['From'] = 'John Doe <jdoe@a.gov.ru>'
+ ... msg['To'] = target
+ ... msg['Bcc'] = 'James Bond <007@sis.gov.uk>'
+ ... emails.append(
+ ... (msg,
+ ... lambda status: stdout.write('SUCCESS: {}\\n'.format(status))))
+ >>> send_emails(emails, use_color=False, dry_run=True)
+ ... # doctest: +REPORT_UDIFF, +NORMALIZE_WHITESPACE
+ sending message to ['Moneypenny <mp@sis.gov.uk>', 'James Bond <007@sis.gov.uk>']...\tDRY-RUN
+ SUCCESS: None
+ sending message to ['M <m@sis.gov.uk>', 'James Bond <007@sis.gov.uk>']...\tDRY-RUN
+ SUCCESS: None
+ """
+ local_smtp = smtp is None
+ highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
+ for msg,callback in emails:
+ sources = [
+ _email_utils.formataddr(a) for a in _pgp_mime.email_sources(msg)]
+ author = sources[0]
+ targets = [
+ _email_utils.formataddr(a) for a in _pgp_mime.email_targets(msg)]
+ _pgp_mime.strip_bcc(msg)
+ if _LOG.level <= _logging.DEBUG:
+ # TODO: remove convert_content_transfer_encoding?
+ #if msg.get('content-transfer-encoding', None) == 'base64':
+ # convert_content_transfer_encoding(msg, '8bit')
+ _LOG.debug(_color_string(
+ '\n{}\n'.format(msg.as_string()), color=lowlight))
+ _write_color('sending message to {}...'.format(targets),
+ color=highlight)
+ if not dry_run:
+ try:
+ if local_smtp:
+ smtp = _smtplib.SMTP('localhost')
+ if debug_target:
+ targets = [debug_target]
+ smtp.sendmail(author, targets, msg.as_string())
+ if local_smtp:
+ smtp.quit()
+ except:
+ _write_color('\tFAILED\n', bad)
+ if callback:
+ callback(False)
+ raise
+ else:
+ _write_color('\tOK\n', good)
+ if callback:
+ callback(True)
+ else:
+ _write_color('\tDRY-RUN\n', good)
+ if callback:
+ callback(None)
+
+def get_address(person, header=False):
+ r"""
+ >>> from pygrader.model.person import Person as Person
+ >>> p = Person(name='Jack', emails=['a@b.net'])
+ >>> get_address(p)
+ 'Jack <a@b.net>'
+
+ Here's a simple unicode example.
+
+ >>> p.name = '✉'
+ >>> get_address(p)
+ '✉ <a@b.net>'
+
+ When you encode addresses that you intend to place in an email
+ header, you should set the `header` option to `True`. This
+ encodes the name portion of the address without encoding the email
+ portion.
+
+ >>> get_address(p, header=True)
+ '=?utf-8?b?4pyJ?= <a@b.net>'
+
+ Note that the address is in the clear. Without the `header`
+ option you'd have to rely on something like:
+
+ >>> from email.header import Header
+ >>> Header(get_address(p), 'utf-8').encode()
+ '=?utf-8?b?4pyJIDxhQGIubmV0Pg==?='
+
+ This can cause trouble when your mailer tries to decode the name
+ following :RFC:`2822`, which limits the locations in which encoded
+ words may appear.
+ """
+ if header:
+ encoding = _pgp_mime.guess_encoding(person.name)
+ if encoding == 'us-ascii':
+ name = person.name
+ else:
+ name = _Header(person.name, encoding).encode()
+ return _email_utils.formataddr((name, person.emails[0]))
+ return _email_utils.formataddr((person.name, person.emails[0]))
+
+def construct_email(author, targets, subject, text, cc=None, sign=True):
+ r"""Built a text/plain email using `Person` instances
+
+ >>> from pygrader.model.person import Person as Person
+ >>> author = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
+ >>> targets = [Person(name='Jill', emails=['c@d.net'])]
+ >>> cc = [Person(name='H.D.', emails=['hd@wall.net'])]
+ >>> msg = construct_email(author, targets, cc=cc,
+ ... subject='Once upon a time', text='Bla bla bla...')
+ >>> print(msg.as_string()) # doctest: +REPORT_UDIFF, +ELLIPSIS
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ Date: ...
+ From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
+ Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
+ To: Jill <c@d.net>
+ Cc: "H.D." <hd@wall.net>
+ Subject: Once upon a time
+ <BLANKLINE>
+ Bla bla bla...
+
+ With unicode text:
+
+ >>> msg = construct_email(author, targets, cc=cc,
+ ... subject='Once upon a time', text='Funky ✉.')
+ >>> print(msg.as_string()) # doctest: +REPORT_UDIFF, +ELLIPSIS
+ Content-Type: text/plain; charset="utf-8"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: base64
+ Content-Disposition: inline
+ Date: ...
+ From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
+ Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
+ To: Jill <c@d.net>
+ Cc: "H.D." <hd@wall.net>
+ Subject: Once upon a time
+ <BLANKLINE>
+ RnVua3kg4pyJLg==
+ <BLANKLINE>
+ """
+ msg = _pgp_mime.encodedMIMEText(text)
+ if sign and author.pgp_key:
+ msg = _pgp_mime.sign(message=msg, sign_as=author.pgp_key)
+
+ msg['Date'] = _email_utils.formatdate()
+ msg['From'] = get_address(author, header=True)
+ msg['Reply-to'] = msg['From']
+ msg['To'] = ', '.join(
+ get_address(target, header=True) for target in targets)
+ if cc:
+ msg['Cc'] = ', '.join(
+ get_address(target, header=True) for target in cc)
+ subject_encoding = _pgp_mime.guess_encoding(subject)
+ if subject_encoding == 'us-ascii':
+ msg['Subject'] = subject
+ else:
+ msg['Subject'] = _Header(subject, subject_encoding)
+
+ return msg
--- /dev/null
+# Copyright
+
+"""Extract message parts with a given MIME type from a mailbox.
+"""
+
+from __future__ import absolute_import
+
+import email.utils as _email_utils
+import hashlib as _hashlib
+import mailbox as _mailbox
+import os as _os
+import os.path as _os_path
+import time as _time
+
+from . import LOG as _LOG
+from .color import color_string as _color_string
+from .color import standard_colors as _standard_colors
+
+
+def message_time(message, use_color=None):
+ highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
+ received = message['Received'] # RFC 822
+ if received is None:
+ mid = message['Message-ID']
+ _LOG.debug(_color_string(
+ string='no Received in {}'.format(mid), color=lowlight))
+ return None
+ date = received.split(';', 1)[1]
+ return _time.mktime(_email_utils.parsedate(date))
+
+def extract_mime(message, mime_type=None, output='.', dry_run=False):
+ _LOG.debug('parsing {}'.format(message['Subject']))
+ time = message_time(message=message)
+ for part in message.walk():
+ fname = part.get_filename()
+ if not fname:
+ continue # don't extract parts without filenames
+ ffname = _os_path.join(output, fname) # full file name
+ ctype = part.get_content_type()
+ if mime_type is None or ctype == mime_type:
+ contents = part.get_payload(decode=True)
+ count = 0
+ base_ffname = ffname
+ is_copy = False
+ while _os_path.exists(ffname):
+ old = _hashlib.sha1(open(ffname, 'rb').read())
+ new = _hashlib.sha1(contents)
+ if old.digest() == new.digest():
+ is_copy = True
+ break
+ count += 1
+ ffname = '{}.{}'.format(base_ffname, count)
+ if is_copy:
+ _LOG.debug('{} already extracted as {}'.format(fname, ffname))
+ continue
+ _LOG.debug('extract {} to {}'.format(fname, ffname))
+ if not dry_run:
+ with open(ffname, 'wb') as f:
+ f.write(contents)
+ if time is not None:
+ _os.utime(ffname, (time, time))
--- /dev/null
+# Copyright
+
+from __future__ import absolute_import
+
+from email import message_from_file as _message_from_file
+from email.header import decode_header as _decode_header
+import hashlib as _hashlib
+import locale as _locale
+import mailbox as _mailbox
+import os as _os
+import os.path as _os_path
+import sys as _sys
+import time as _time
+
+from pgp_mime import verify as _verify
+
+from . import LOG as _LOG
+from .color import standard_colors as _standard_colors
+from .color import color_string as _color_string
+from .extract_mime import extract_mime as _extract_mime
+from .extract_mime import message_time as _message_time
+from .storage import assignment_path as _assignment_path
+from .storage import set_late as _set_late
+
+
+def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
+ output=None, max_late=0, use_color=None, dry_run=False, **kwargs):
+ """Run from procmail to sort incomming submissions
+
+ For example, you can setup your ``.procmailrc`` like this::
+
+ SHELL=/bin/sh
+ DEFAULT=$MAIL
+ MAILDIR=$HOME/mail
+ DEFAULT=$MAILDIR/mbox
+ LOGFILE=$MAILDIR/procmail.log
+ #VERBOSE=yes
+ PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
+
+ # Grab all incoming homeworks emails. This rule eats matching emails
+ # (i.e. no further procmail processing).
+ :0
+ * ^Subject:.*\[phys160-sub]
+ | "$PYGRADE_MAILPIPE" mailpipe
+
+ If you don't want procmail to eat the message, you can use the
+ ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
+ """
+ if stream is None:
+ stream = _sys.stdin
+ for msg,person,assignment,time in _load_messages(
+ course=course, stream=stream, mailbox=mailbox, input_=input_,
+ output=output, use_color=use_color, dry_run=dry_run):
+ assignment_path = _assignment_path(basedir, assignment, person)
+ _save_local_message_copy(
+ msg=msg, person=person, assignment_path=assignment_path,
+ use_color=use_color, dry_run=dry_run)
+ _extract_mime(message=msg, output=assignment_path, dry_run=dry_run)
+ _check_late(
+ basedir=basedir, assignment=assignment, person=person, time=time,
+ max_late=max_late, use_color=use_color, dry_run=dry_run)
+
+def _load_messages(course, stream, mailbox=None, input_=None, output=None,
+ use_color=None, dry_run=False):
+ if mailbox is None:
+ mbox = None
+ messages = [(None,_message_from_file(stream))]
+ elif mailbox == 'mbox':
+ mbox = _mailbox.mbox(input_, factory=None, create=False)
+ messages = mbox.items()
+ if output is not None:
+ ombox = _mailbox.mbox(output, factory=None, create=True)
+ elif mailbox == 'maildir':
+ mbox = _mailbox.Maildir(input_, factory=None, create=False)
+ messages = mbox.items()
+ if output is not None:
+ ombox = _mailbox.Maildir(output, factory=None, create=True)
+ else:
+ raise ValueError(mailbox)
+ for key,msg in messages:
+ ret = _parse_message(
+ course=course, msg=msg, use_color=use_color)
+ if ret:
+ if mbox is not None and output is not None and dry_run is False:
+ # move message from input mailbox to output mailbox
+ ombox.add(msg)
+ del mbox[key]
+ yield ret
+
+def _parse_message(course, msg, use_color=None):
+ highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
+ mid = msg['Message-ID']
+ sender = msg['Return-Path'] # RFC 822
+ if sender is None:
+ _LOG.debug(_color_string(
+ string='no Return-Path in {}'.format(mid), color=lowlight))
+ return None
+ sender = sender[1:-1] # strip wrapping '<' and '>'
+
+ people = list(course.find_people(email=sender))
+ if len(people) == 0:
+ _LOG.warn(_color_string(
+ string='no person found to match {}'.format(sender),
+ color=bad))
+ return None
+ if len(people) > 1:
+ _LOG.warn(_color_string(
+ string='multiple people match {} ({})'.format(
+ sender, ', '.join(str(p) for p in people)),
+ color=bad))
+ return None
+ person = people[0]
+
+ if person.pgp_key:
+ msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
+ if msg is None:
+ return None
+
+ if msg['Subject'] is None:
+ _LOG.warn(_color_string(
+ string='no subject in {}'.format(mid), color=bad))
+ return None
+ parts = _decode_header(msg['Subject'])
+ if len(parts) != 1:
+ _LOG.warn(_color_string(
+ string='multi-part header {}'.format(parts), color=bad))
+ return None
+ subject,encoding = parts[0]
+ if encoding is None:
+ encoding = 'ascii'
+ _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
+ subject = subject.lower().replace('#', '')
+ for assignment in course.assignments:
+ if _match_assignment(assignment, subject):
+ break
+ if not _match_assignment(assignment, subject):
+ _LOG.warn(_color_string(
+ string='no assignment found in {}'.format(repr(subject)),
+ color=bad))
+ return None
+
+ time = _message_time(message=msg, use_color=use_color)
+ return (msg, person, assignment, time)
+
+def _match_assignment(assignment, subject):
+ return assignment.name.lower() in subject
+
+def _save_local_message_copy(msg, person, assignment_path, use_color=None,
+ dry_run=False):
+ highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
+ try:
+ _os.makedirs(assignment_path)
+ except OSError:
+ pass
+ mpath = _os_path.join(assignment_path, 'mail')
+ try:
+ mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
+ except _mailbox.NoSuchMailboxError as e:
+ _LOG.debug(_color_string(
+ string='could not open mailbox at {}'.format(mpath),
+ color=bad))
+ mbox = None
+ new_msg = True
+ else:
+ new_msg = True
+ for other_msg in mbox:
+ if other_msg['Message-ID'] == msg['Message-ID']:
+ new_msg = False
+ break
+ if new_msg:
+ _LOG.debug(_color_string(
+ string='saving email from {} to {}'.format(
+ person, assignment_path), color=good))
+ if mbox is not None and not dry_run:
+ mdmsg = _mailbox.MaildirMessage(msg)
+ mdmsg.add_flag('S')
+ mbox.add(mdmsg)
+ mbox.close()
+ else:
+ _LOG.debug(_color_string(
+ string='already found {} in {}'.format(
+ msg['Message-ID'], mpath), color=good))
+
+def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
+ dry_run=False):
+ highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
+ if time > assignment.due + max_late:
+ dt = time - assignment.due
+ _LOG.warn(_color_string(
+ string='{} {} late by {} seconds ({} hours)'.format(
+ person.name, assignment.name, dt, dt/3600.),
+ color=bad))
+ if not dry_run:
+ _set_late(basedir=basedir, assignment=assignment, person=person)
+
+def _get_verified_message(message, pgp_key, use_color=None):
+ """
+
+ >>> from copy import deepcopy
+ >>> from pgp_mime import sign, encodedMIMEText
+
+ The student composes a message...
+
+ >>> message = encodedMIMEText('1.23 joules')
+
+ ... and signs it (with the pgp-mime test key).
+
+ >>> signed = sign(message, sign_as='4332B6E3')
+
+ As it is being delivered, the message picks up extra headers.
+
+ >>> signed['Message-ID'] = '<01234567@home.net>'
+ >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
+ >>> signed['Received'] = 'from smtp.home.net ...'
+
+ We check that the message is signed, and that it is signed by the
+ appropriate key.
+
+ >>> our_message = _get_verified_message(
+ ... deepcopy(signed), pgp_key='4332B6E3')
+ >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ Message-ID: <01234567@home.net>
+ Received: from smtp.mail.uu.edu ...
+ Received: from smtp.home.net ...
+ <BLANKLINE>
+ 1.23 joules
+
+ If it is signed, but not by the right key, we get ``None``.
+
+ >>> print(_get_verified_message(
+ ... deepcopy(signed), pgp_key='01234567'))
+ None
+
+ If it is not signed at all, we get ``None``.
+
+ >>> print(_get_verified_message(
+ ... deepcopy(message), pgp_key='4332B6E3'))
+ None
+ """
+ highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
+ mid = message['message-id']
+ try:
+ decrypted,verified,gpg_message = _verify(message=message)
+ except (ValueError, AssertionError):
+ _LOG.warn(_color_string(
+ string='could not verify {} (not signed?)'.format(mid),
+ color=bad))
+ return None
+ _LOG.info(_color_string(gpg_message, color=lowlight))
+ if not verified:
+ _LOG.warn(_color_string(
+ string='{} has an invalid signature'.format(mid), color=bad))
+ pass #return None
+ print(gpg_message)
+ for k,v in message.items(): # copy over useful headers
+ if k.lower() not in ['content-type',
+ 'mime-version',
+ 'content-disposition',
+ ]:
+ decrypted[k] = v
+ return decrypted
--- /dev/null
+# Copyright
--- /dev/null
+# Copyright
+
+class Assignment (object):
+ def __init__(self, name, points=1, weight=0, due=0):
+ self.name = name
+ self.points = points
+ self.weight = weight
+ self.due = due
+
+ def __str__(self):
+ return '<{} {}>'.format(type(self).__name__, self.name)
+
+ def __lt__(self, other):
+ if self.due < other.due:
+ return True
+ elif other.due < self.due:
+ return False
+ return self.name < other.name
--- /dev/null
+# Copyright
+
+from .. import LOG as _LOG
+
+
+class Course (object):
+ def __init__(self, assignments=None, people=None, grades=None):
+ if assignments is None:
+ assignments = []
+ self.assignments = sorted(assignments)
+ if people is None:
+ people = []
+ self.people = sorted(people)
+ if grades is None:
+ grades = []
+ self.grades = sorted(grades)
+
+ def assignment(self, name):
+ for assignment in self.assignments:
+ if assignment.name == name:
+ return assignment
+ raise ValueError(name)
+
+ def active_assignments(self):
+ return sorted(set(grade.assignment for grade in self.grades))
+
+ def active_groups(self):
+ groups = set()
+ for person in self.people:
+ groups.update(person.groups)
+ return sorted(groups)
+
+ def find_people(self, name=None, email=None, group=None):
+ """Yield ``Person``\s that match ``name``, ``email``, and ``group``
+
+ The value of ``None`` matches any person.
+
+ >>> from pygrade.model.person import Person
+ >>> c = Course(people=[
+ ... Person(name='Bilbo Baggins',
+ ... emails=['bb@shire.org', 'bb@greyhavens.net'],
+ ... aliases=['Billy'],
+ ... groups=['students', 'assistants']),
+ ... Person(name='Frodo Baggins',
+ ... emails=['fb@shire.org'],
+ ... groups=['students']),
+ ... ])
+ >>> for person in c.find_people(name='Bilbo Baggins'):
+ ... print(person)
+ <Person Bilbo Baggins>
+ >>> for person in c.find_people(name='Billy'):
+ ... print(person)
+ <Person Bilbo Baggins>
+ >>> for person in c.find_people(email='bb@greyhavens.net'):
+ ... print(person)
+ <Person Bilbo Baggins>
+ >>> for person in c.find_people(group='assistants'):
+ ... print(person)
+ <Person Bilbo Baggins>
+ >>> for person in c.find_people(group='students'):
+ ... print(person)
+ <Person Bilbo Baggins>
+ <Person Frodo Baggins>
+ """
+ for person in self.people:
+ name_match = (person.name == name or
+ (person.aliases and name in person.aliases))
+ email_match = email in person.emails
+ group_match = group in person.groups
+ matched = True
+ for (key,kmatched) in [(name, name_match),
+ (email, email_match),
+ (group, group_match),
+ ]:
+ if key is not None and not kmatched:
+ matched = False
+ break
+ if matched:
+ yield person
+
+ def person(self, **kwargs):
+ people = list(self.find_people(**kwargs))
+ assert len(people) == 1, '{} -> {}'.format(kwargs, people)
+ return people[0]
+
+ def grade(self, student, assignment):
+ """Return the ``Grade`` that matches ``Student`` and ``Assignment``
+
+ >>> from pygrade.model.assignment import Assignment
+ >>> from pygrade.model.grade import Grade
+ >>> from pygrade.model.person import Person
+ >>> p = Person(name='Bilbo Baggins')
+ >>> a = Assignment(name='Exam 1')
+ >>> g = Grade(student=p, assignment=a, points=10)
+ >>> c = Course(assignments=[a], people=[p], grades=[g])
+ >>> print(c.grade(student=p, assignment=a))
+ <Grade Bilbo Baggins:Exam 1>
+ """
+ for grade in self.grades:
+ if grade.student == student and grade.assignment == assignment:
+ return grade
+ raise ValueError((student, assignment))
+
+ def total(self, student):
+ total = 0
+ for assignment in self.assignments:
+ try:
+ grade = self.grade(student=student, assignment=assignment)
+ except ValueError:
+ continue
+ total += float(grade.points)/assignment.points * assignment.weight
+ return total
--- /dev/null
+# Copyright
+
+class Grade (object):
+ def __init__(self, student, assignment, points, comment=None,
+ late=False, notified=False):
+ self.student = student
+ self.assignment = assignment
+ self.points = points
+ self.comment = comment
+ self.late = late
+ self.notified = notified
+
+ def __str__(self):
+ return '<{} {}:{}>'.format(
+ type(self).__name__, self.student.name, self.assignment.name)
+
+ def __lt__(self, other):
+ if self.student < other.student:
+ return True
+ elif other.student < self.student:
+ return False
+ return self.assignment < other.assignment
--- /dev/null
+# Copyright
+
+class Person (object):
+ def __init__(self, name, emails=None, pgp_key=None, aliases=None,
+ groups=None):
+ self.name = name
+ self.emails = emails
+ self.pgp_key = pgp_key
+ if not aliases:
+ aliases = [self.name]
+ self.aliases = aliases
+ self.groups = groups
+
+ def __str__(self):
+ return '<{} {}>'.format(type(self).__name__, self.name)
+
+ def __lt__(self, other):
+ return self.name < other.name
+
+ def alias(self):
+ """Return a good alias for direct address
+ """
+ try:
+ return self.aliases[0]
+ except KeyError:
+ return self.name
--- /dev/null
+# Copyright
+
+from __future__ import absolute_import
+
+import calendar as _calendar
+import configparser as _configparser
+import email.utils as _email_utils
+import io as _io
+import os as _os
+import os.path as _os_path
+import re as _re
+import sys as _sys
+import time as _time
+
+from . import LOG as _LOG
+from . import ENCODING as _ENCODING
+from .model.assignment import Assignment as _Assignment
+from .model.course import Course as _Course
+from .model.grade import Grade as _Grade
+from .model.person import Person as _Person
+from .todo import newer
+
+
+_DATE_REGEXP = _re.compile('^([^T]*)(T?)([^TZ+-.]*)([.]?[0-9]*)([+-][0-9:]*|Z?)$')
+
+
+def load_course(basedir):
+ _LOG.debug('loading course from {}'.format(basedir))
+ config = _configparser.ConfigParser()
+ config.read([_os_path.join(basedir, 'course.conf')])
+ names = {}
+ for option in ['assignments', 'professors', 'assistants', 'students']:
+ names[option] = [
+ a.strip() for a in config.get('course', option).split(',')]
+ assignments = []
+ for assignment in names['assignments']:
+ _LOG.debug('loading assignment {}'.format(assignment))
+ assignments.append(load_assignment(
+ name=assignment, data=dict(config.items(assignment))))
+ people = {}
+ for group in ['professors', 'assistants', 'students']:
+ for person in names[group]:
+ if person in people:
+ _LOG.debug('adding person {} to group {}'.format(
+ person, group))
+ people[person].groups.append(group)
+ else:
+ _LOG.debug('loading person {} in group {}'.format(
+ person, group))
+ people[person] = load_person(
+ name=person, data=dict(config.items(person)))
+ people[person].groups = [group]
+ people = people.values()
+ grades = list(load_grades(basedir, assignments, people))
+ return _Course(assignments=assignments, people=people, grades=grades)
+
+def parse_date(string):
+ """Parse dates given using the W3C DTF profile of ISO 8601.
+
+ The following are legal formats::
+
+ YYYY (e.g. 2000)
+ YYYY-MM (e.g. 2000-02)
+ YYYY-MM-DD (e.g. 2000-02-12)
+ YYYY-MM-DDThh:mmTZD (e.g. 2000-02-12T06:05+05:30)
+ YYYY-MM-DDThh:mm:ssTZD (e.g. 2000-02-12T06:05:30+05:30)
+ YYYY-MM-DDThh:mm:ss.sTZD (e.g. 2000-02-12T06:05:30.45+05:30)
+
+ Note that the TZD can be either the capital letter `Z` to indicate
+ UTC time, a string in the format +hh:mm to indicate a local time
+ expressed with a time zone hh hours and mm minutes ahead of UTC or
+ -hh:mm to indicate a local time expressed with a time zone hh
+ hours and mm minutes behind UTC.
+
+ >>> import calendar
+ >>> import email.utils
+ >>> import time
+ >>> ref = calendar.timegm(time.strptime('2000', '%Y'))
+ >>> y = parse_date('2000')
+ >>> y - ref # seconds between y and ref
+ 0
+ >>> ym = parse_date('2000-02')
+ >>> (ym - y)/(3600.*24) # days between ym and y
+ 31.0
+ >>> ymd = parse_date('2000-02-12')
+ >>> (ymd - ym)/(3600.*24) # days between ymd and ym
+ 11.0
+ >>> ymdhm = parse_date('2000-02-12T06:05+05:30')
+ >>> (ymdhm - ymd)/60. # minutes between ymdhm and ymd
+ 35.0
+ >>> (ymdhm - parse_date('2000-02-12T06:05Z'))/3600.
+ -5.5
+ >>> ymdhms = parse_date('2000-02-12T06:05:30+05:30')
+ >>> ymdhms - ymdhm
+ 30
+ >>> (ymdhms - parse_date('2000-02-12T06:05:30Z'))/3600.
+ -5.5
+ >>> ymdhms_ms = parse_date('2000-02-12T06:05:30.45+05:30')
+ >>> ymdhms_ms - ymdhms # doctest: +ELLIPSIS
+ 0.45000...
+ >>> (ymdhms_ms - parse_date('2000-02-12T06:05:30.45Z'))/3600.
+ -5.5
+ >>> p = parse_date('1994-11-05T08:15:30-05:00')
+ >>> email.utils.formatdate(p, localtime=True)
+ 'Sat, 05 Nov 1994 08:15:30 -0500'
+ >>> p - parse_date('1994-11-05T13:15:30Z')
+ 0
+ """
+ m = _DATE_REGEXP.match(string)
+ if not m:
+ raise ValueError(string)
+ date,t,time,ms,zone = m.groups()
+ ret = None
+ if t:
+ date += 'T' + time
+ error = None
+ for fmt in ['%Y-%m-%dT%H:%M:%S',
+ '%Y-%m-%dT%H:%M',
+ '%Y-%m-%d',
+ '%Y-%m',
+ '%Y',
+ ]:
+ try:
+ ret = _time.strptime(date, fmt)
+ except ValueError as e:
+ error = e
+ else:
+ break
+ if ret is None:
+ raise error
+ ret = list(ret)
+ ret[-1] = 0 # don't use daylight savings time
+ ret = _calendar.timegm(ret)
+ if ms:
+ ret += float(ms)
+ if zone and zone != 'Z':
+ sign = int(zone[1] + '1')
+ hour,minute = map(int, zone.split(':', 1))
+ offset = sign*(3600*hour + 60*minute)
+ ret -= offset
+ return ret
+
+def load_assignment(name, data):
+ r"""Load an assignment from a ``dict``
+
+ >>> from email.utils import formatdate
+ >>> a = load_assignment(
+ ... name='Attendance 1',
+ ... data={'points': '1',
+ ... 'weight': '0.1/2',
+ ... 'due': '2011-10-04T00:00-04:00',
+ ... })
+ >>> print('{0.name} (points: {0.points}, weight: {0.weight}, due: {0.due})'.format(a))
+ Attendance 1 (points: 1, weight: 0.05, due: 1317700800)
+ >>> print(formatdate(a.due, localtime=True))
+ Tue, 04 Oct 2011 00:00:00 -0400
+ """
+ points = int(data['points'])
+ wterms = data['weight'].split('/')
+ if len(wterms) == 1:
+ weight = float(wterms[0])
+ else:
+ assert len(wterms) == 2, wterms
+ weight = float(wterms[0])/float(wterms[1])
+ due = parse_date(data['due'])
+ return _Assignment(name=name, points=points, weight=weight, due=due)
+
+def load_person(name, data={}):
+ r"""Load a person from a ``dict``
+
+ >>> from io import StringIO
+ >>> stream = StringIO('''#comment line
+ ... Tom Bombadil <tbomb@oldforest.net> # post address comment
+ ... Tom Bombadil <yellow.boots@oldforest.net>
+ ... Goldberry <gb@oldforest.net>
+ ... ''')
+
+ >>> p = load_person(
+ ... name='Gandalf',
+ ... data={'nickname': 'G-Man',
+ ... 'emails': 'g@grey.edu, g@greyhavens.net',
+ ... 'pgp-key': '0x0123456789ABCDEF',
+ ... })
+ >>> print('{0.name}: {0.emails}'.format(p))
+ Gandalf: ['g@grey.edu', 'g@greyhavens.net'] | 0x0123456789ABCDEF
+ >>> p = load_person(name='Gandalf')
+ >>> print('{0.name}: {0.emails} | {0.pgp_key}'.format(p))
+ Gandalf: None | None
+ """
+ kwargs = {}
+ emails = [x.strip() for x in data.get('emails', '').split(',')]
+ emails = list(filter(bool, emails)) # remove blank emails
+ if emails:
+ kwargs['emails'] = emails
+ nickname = data.get('nickname', None)
+ if nickname:
+ kwargs['aliases'] = [nickname]
+ pgp_key = data.get('pgp-key', None)
+ if pgp_key:
+ kwargs['pgp_key'] = pgp_key
+ return _Person(name=name, **kwargs)
+
+def load_grades(basedir, assignments, people):
+ for assignment in assignments:
+ for person in people:
+ _LOG.debug('loading {} grade for {}'.format(assignment, person))
+ path = assignment_path(basedir, assignment, person)
+ gpath = _os_path.join(path, 'grade')
+ try:
+ g = _load_grade(_io.open(gpath, 'r', encoding=_ENCODING),
+ assignment, person)
+ except IOError:
+ continue
+ #g.late = _os.stat(gpath).st_mtime > assignment.due
+ g.late = _os_path.exists(_os_path.join(path, 'late'))
+ npath = _os_path.join(path, 'notified')
+ if _os_path.exists(npath):
+ g.notified = newer(npath, gpath)
+ else:
+ g.notified = False
+ yield g
+
+def _load_grade(stream, assignment, person):
+ try:
+ points = float(stream.readline())
+ except ValueError:
+ _sys.stderr.write('failure reading {}, {}\n'.format(
+ assignment.name, person.name))
+ raise
+ comment = stream.read().strip() or None
+ return _Grade(
+ student=person, assignment=assignment, points=points, comment=comment)
+
+def assignment_path(basedir, assignment, person):
+ return _os_path.join(basedir,
+ _filesystem_name(person.name),
+ _filesystem_name(assignment.name))
+
+def _filesystem_name(name):
+ for a,b in [(' ', '_'), ('.', ''), ("'", ''), ('"', '')]:
+ name = name.replace(a, b)
+ return name
+
+def set_notified(basedir, grade):
+ """Mark `grade.student` as notified about `grade`
+ """
+ path = assignment_path(
+ basedir=basedir, assignment=grade.assignment, person=grade.student)
+ npath = _os_path.join(path, 'notified')
+ _touch(npath)
+
+def set_late(basedir, assignment, person):
+ path = assignment_path(
+ basedir=basedir, assignment=assignment, person=person)
+ Lpath = _os_path.join(path, 'late')
+ _touch(Lpath)
+
+def _touch(path):
+ """Touch a file (`path` is created if it doesn't already exist)
+
+ Also updates the access and modification times to the current
+ time.
+
+ >>> from os import listdir, rmdir, unlink
+ >>> from os.path import join
+ >>> from tempfile import mkdtemp
+ >>> d = mkdtemp(prefix='pygrader')
+ >>> listdir(d)
+ []
+ >>> p = join(d, 'touched')
+ >>> _touch(p)
+ >>> listdir(d)
+ ['touched']
+ >>> _touch(p)
+ >>> unlink(p)
+ >>> rmdir(d)
+ """
+ with open(path, 'a') as f:
+ pass
+ _os.utime(path, None)
+
+def initialize(basedir, course, dry_run=False, **kwargs):
+ """Stub out the directory tree based on the course configuration.
+ """
+ for person in course.people:
+ for assignment in course.assignments:
+ path = assignment_path(basedir, assignment, person)
+ if dry_run: # we'll need to guess if mkdirs would work
+ if not _os_path.exists(path):
+ _LOG.debug('creating {}'.format(path))
+ else:
+ try:
+ _os.makedirs(path)
+ except OSError:
+ continue
+ else:
+ _LOG.debug('creating {}'.format(path))
--- /dev/null
+# Copyright
+
+import sys as _sys
+
+try:
+ import numpy as _numpy
+except ImportError:
+ raise # TODO work around
+
+from .color import standard_colors as _standard_colors
+from .color import write_color as _write_color
+
+
+def tabulate(course, statistics=False, stream=None, use_color=False, **kwargs):
+ """Return a table of student's grades to date
+ """
+ if stream is None:
+ stream = _sys.stdout
+ highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
+ colors = [highlight, lowlight]
+ assignments = sorted(set(
+ grade.assignment for grade in course.grades))
+ students = sorted(set(grade.student for grade in course.grades))
+ _write_color(string='Student', color=colors[0], stream=stream)
+ for i,assignment in enumerate(assignments):
+ string = '\t{}'.format(assignment.name)
+ color = colors[(i+1)%len(colors)]
+ _write_color(string=string, color=color, stream=stream)
+ if len(assignments) == len(course.assignments):
+ string = '\t{}'.format('Total')
+ color = colors[(i+2)%len(colors)]
+ _write_color(string=string, color=color, stream=stream)
+ _write_color(string='\n', stream=stream)
+ for student in students:
+ _write_color(string=student.name, color=colors[0], stream=stream)
+ for i,assignment in enumerate(assignments):
+ try:
+ grade = course.grade(student=student, assignment=assignment)
+ gs = str(grade.points)
+ except ValueError:
+ gs = '-'
+ string = '\t{}'.format(gs)
+ color = colors[(i+1)%len(colors)]
+ _write_color(string=string, color=color, stream=stream)
+ if len(assignments) == len(course.assignments):
+ string = '\t{}'.format(course.total(student))
+ color = colors[(i+2)%len(colors)]
+ _write_color(string=string, color=color, stream=stream)
+ _write_color(string='\n', stream=stream)
+ if statistics:
+ _write_color(string='--\n', stream=stream)
+ for stat in ['Mean', 'Std. Dev.']:
+ _write_color(string=stat, color=colors[0], stream=stream)
+ for i,assignment in enumerate(assignments):
+ color = colors[(i+1)%len(colors)]
+ grades = [g for g in course.grades
+ if g.assignment == assignment]
+ gs = _numpy.array([g.points for g in grades])
+ if stat == 'Mean':
+ sval = gs.mean()
+ elif stat == 'Std. Dev.':
+ sval = gs.std()
+ else:
+ raise NotImplementedError(stat)
+ string = '\t{:.2f}'.format(sval)
+ _write_color(string=string, color=color, stream=stream)
+ if len(assignments) == len(course.assignments):
+ gs = _numpy.array([course.total(s) for s in students])
+ if stat == 'Mean':
+ sval = gs.mean()
+ elif stat == 'Std. Dev.':
+ sval = gs.std()
+ else:
+ raise NotImplementedError(stat)
+ string = '\t{}'.format(sval)
+ color = colors[(i+2)%len(colors)]
+ _write_color(string=string, color=color, stream=stream)
+ _write_color(string='\n', stream=stream)
--- /dev/null
+# Copyright
+
+import io as _io
+
+from jinja2 import Template
+
+from . import LOG as _LOG
+from .email import construct_email as _construct_email
+from .email import send_emails as _send_emails
+from .storage import set_notified as _set_notified
+from .tabulate import tabulate as _tabulate
+
+
+ASSIGNMENT_TEMPLATE = Template("""
+{{ grade.student.alias() }},
+
+You got {{ grade.points }} out of {{ grade.assignment.points }} available points on {{ grade.assignment.name }}.
+{% if grade.comment %}
+{{ grade.comment }}
+{% endif %}
+Yours,
+{{ author.alias() }}
+""".strip())
+#{{ grade.comment|wordwrap }}
+
+STUDENT_TEMPLATE = Template("""
+{{ grades[0].student.alias() }},
+
+Grades:
+{%- for grade in grades %}
+ * {{ grade.assignment.name }}:\t{{ grade.points }} out of {{ grade.assignment.points }} available points.
+{%- endfor %}
+
+Comments:
+{%- for grade in grades -%}
+{% if grade.comment %}
+
+{{ grade.assignment.name }}
+
+{{ grade.comment }}
+{%- endif %}
+{% endfor %}
+Yours,
+{{ author.alias() }}
+""".strip())
+
+COURSE_TEMPLATE = Template("""
+{{ target }},
+
+Here are the (tab delimited) course grades to date:
+
+{{ table }}
+The available points (and weights) for each assignment are:
+{%- for assignment in course.active_assignments() %}
+ * {{ assignment.name }}:\t{{ assignment.points }}\t{{ assignment.weight }}
+{%- endfor %}
+
+Yours,
+{{ author.alias() }}
+""".strip())
+
+
+
+
+class NotifiedCallback (object):
+ """A callback for marking notifications with `_send_emails`
+ """
+ def __init__(self, basedir, grades):
+ self.basedir = basedir
+ self.grades = grades
+
+ def __call__(self, success):
+ if success:
+ for grade in self.grades:
+ _set_notified(basedir=self.basedir, grade=grade)
+
+
+def join_with_and(strings):
+ """Join a list of strings.
+
+ >>> join_with_and(['a','b','c'])
+ 'a, b, and c'
+ >>> join_with_and(['a','b'])
+ 'a and b'
+ >>> join_with_and(['a'])
+ 'a'
+ """
+ ret = [strings[0]]
+ for i,s in enumerate(strings[1:]):
+ if len(strings) > 2:
+ ret.append(', ')
+ else:
+ ret.append(' ')
+ if i == len(strings)-2:
+ ret.append('and ')
+ ret.append(s)
+ return ''.join(ret)
+
+def assignment_email(basedir, author, course, assignment, student=None,
+ cc=None, smtp=None, use_color=False, debug_target=None,
+ dry_run=False):
+ """Send each student an email with their grade on `assignment`
+ """
+ _send_emails(
+ emails=_assignment_email(
+ basedir=basedir, author=author, course=course,
+ assignment=assignment, student=student, cc=cc),
+ smtp=smtp, use_color=use_color,
+ debug_target=debug_target, dry_run=dry_run)
+
+def _assignment_email(basedir, author, course, assignment, student=None,
+ cc=None):
+ """Iterate through composed assignment `Message`\s
+ """
+ if student:
+ students = [student]
+ else:
+ students = course.people
+ for student in students:
+ try:
+ grade = course.grade(student=student, assignment=assignment)
+ except ValueError:
+ continue
+ if grade.notified:
+ continue
+ yield (construct_assignment_email(author=author, grade=grade, cc=cc),
+ NotifiedCallback(basedir=basedir, grades=[grade]))
+
+def construct_assignment_email(author, grade, cc=None):
+ """Construct a `Message` notfiying a student of `grade`
+
+ >>> from pygrader.model.person import Person
+ >>> from pygrader.model.assignment import Assignment
+ >>> from pygrader.model.grade import Grade
+ >>> author = Person(name='Jack', emails=['a@b.net'])
+ >>> student = Person(name='Jill', emails=['c@d.net'])
+ >>> assignment = Assignment(name='Exam 1', points=3)
+ >>> grade = Grade(student=student, assignment=assignment, points=2)
+ >>> msg = construct_assignment_email(author=author, grade=grade)
+ >>> print(msg.as_string()) # doctest: +REPORT_UDIFF, +ELLIPSIS
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ Date: ...
+ From: Jack <a@b.net>
+ Reply-to: Jack <a@b.net>
+ To: Jill <c@d.net>
+ Subject: Your Exam 1 grade
+ <BLANKLINE>
+ Jill,
+ <BLANKLINE>
+ You got 2 out of 3 available points on Exam 1.
+ <BLANKLINE>
+ Yours,
+ Jack
+
+ >>> grade.comment = ('Some comment bla bla bla.').strip()
+ >>> msg = construct_assignment_email(author=author, grade=grade)
+ >>> print(msg.as_string()) # doctest: +REPORT_UDIFF, +ELLIPSIS
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ Date: ...
+ From: Jack <a@b.net>
+ Reply-to: Jack <a@b.net>
+ To: Jill <c@d.net>
+ Subject: Your Exam 1 grade
+ <BLANKLINE>
+ Jill,
+ <BLANKLINE>
+ You got 2 out of 3 available points on Exam 1.
+ <BLANKLINE>
+ Some comment bla bla bla.
+ <BLANKLINE>
+ Yours,
+ Jack
+ """
+ return _construct_email(
+ author=author, targets=[grade.student], cc=cc,
+ subject='Your {} grade'.format(grade.assignment.name),
+ text=ASSIGNMENT_TEMPLATE.render(author=author, grade=grade))
+
+def student_email(basedir, author, course, student=None, cc=None, old=False,
+ smtp=None, use_color=False, debug_target=None,
+ dry_run=False):
+ """Send each student an email with their grade to date
+ """
+ _send_emails(
+ emails=_student_email(
+ basedir=basedir, author=author, course=course, student=student,
+ cc=cc, old=old),
+ smtp=smtp, use_color=use_color, debug_target=debug_target,
+ dry_run=dry_run)
+
+def _student_email(basedir, author, course, student=None, cc=None, old=False):
+ """Iterate through composed student `Message`\s
+ """
+ if student:
+ students = [student]
+ else:
+ students = course.people
+ for student in students:
+ grades = [g for g in course.grades if g.student == student]
+ if not old:
+ grades = [g for g in grades if not g.notified]
+ if not grades:
+ continue
+ yield (construct_student_email(author=author, grades=grades, cc=cc),
+ NotifiedCallback(basedir=basedir, grades=grades))
+
+def construct_student_email(author, grades, cc=None):
+ """Construct a `Message` notfiying a student of `grade`
+
+ >>> from pygrader.model.person import Person
+ >>> from pygrader.model.assignment import Assignment
+ >>> from pygrader.model.grade import Grade
+ >>> author = Person(name='Jack', emails=['a@b.net'])
+ >>> student = Person(name='Jill', emails=['c@d.net'])
+ >>> grades = []
+ >>> for name,points in [('Homework 1', 3), ('Exam 1', 10)]:
+ ... assignment = Assignment(name=name, points=points)
+ ... grade = Grade(
+ ... student=student, assignment=assignment,
+ ... points=int(points/2.0))
+ ... grades.append(grade)
+ >>> msg = construct_student_email(author=author, grades=grades)
+ >>> print(msg.as_string())
+ ... # doctest: +REPORT_UDIFF, +ELLIPSIS, +NORMALIZE_WHITESPACE
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ Date: ...
+ From: Jack <a@b.net>
+ Reply-to: Jack <a@b.net>
+ To: Jill <c@d.net>
+ Subject: Your grade
+ <BLANKLINE>
+ Jill,
+ <BLANKLINE>
+ Grades:
+ * Exam 1:\t5 out of 10 available points.
+ * Homework 1:\t1 out of 3 available points.
+ <BLANKLINE>
+ Comments:
+ <BLANKLINE>
+ <BLANKLINE>
+ Yours,
+ Jack
+
+ >>> grades[0].comment = ('Bla bla bla. '*20).strip()
+ >>> grades[1].comment = ('Hello world')
+ >>> msg = construct_student_email(author=author, grades=grades)
+ >>> print(msg.as_string())
+ ... # doctest: +REPORT_UDIFF, +ELLIPSIS, +NORMALIZE_WHITESPACE
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ Date: ...
+ From: Jack <a@b.net>
+ Reply-to: Jack <a@b.net>
+ To: Jill <c@d.net>
+ Subject: Your grade
+ <BLANKLINE>
+ Jill,
+ <BLANKLINE>
+ Grades:
+ * Exam 1:\t5 out of 10 available points.
+ * Homework 1:\t1 out of 3 available points.
+ <BLANKLINE>
+ Comments:
+ <BLANKLINE>
+ Exam 1
+ <BLANKLINE>
+ Hello world
+ <BLANKLINE>
+ <BLANKLINE>
+ <BLANKLINE>
+ Homework 1
+ <BLANKLINE>
+ Bla bla bla. Bla bla bla. Bla bla bla. Bla bla bla. Bla bla bla. Bla bla
+ bla. Bla bla bla. Bla bla bla. Bla bla bla. Bla bla bla. Bla bla bla. Bla
+ bla bla. Bla bla bla. Bla bla bla. Bla bla bla. Bla bla bla. Bla bla bla.
+ Bla bla bla. Bla bla bla. Bla bla bla.
+ <BLANKLINE>
+ <BLANKLINE>
+ Yours,
+ Jack
+ """
+ students = set(g.student for g in grades)
+ assert len(students) == 1, students
+ return _construct_email(
+ author=author, targets=[grades[0].student], cc=cc,
+ subject='Your grade',
+ text=STUDENT_TEMPLATE.render(author=author, grades=sorted(grades)))
+
+def course_email(basedir, author, course, targets, assignment=None,
+ student=None, cc=None, smtp=None, use_color=False,
+ debug_target=None, dry_run=False):
+ """Send the professor an email with all student grades to date
+ """
+ _send_emails(
+ emails=_course_email(
+ basedir=basedir, author=author, course=course, targets=targets,
+ assignment=assignment, student=student, cc=cc),
+ smtp=smtp, use_color=use_color, debug_target=debug_target,
+ dry_run=dry_run)
+
+def _course_email(basedir, author, course, targets, assignment=None,
+ student=None, cc=None):
+ """Iterate through composed course `Message`\s
+ """
+ yield (construct_course_email(
+ author=author, course=course, targets=targets, cc=cc),
+ None)
+
+def construct_course_email(author, course, targets, cc=None):
+ """Construct a `Message` notfiying a professor of all grades to date
+
+ >>> from pygrader.model.person import Person
+ >>> from pygrader.model.assignment import Assignment
+ >>> from pygrader.model.grade import Grade
+ >>> from pygrader.model.course import Course
+ >>> author = Person(name='Jack', emails=['a@b.net'])
+ >>> student = Person(name='Jill', emails=['c@d.net'])
+ >>> prof = Person(name='H.D.', emails=['hd@wall.net'])
+ >>> grades = []
+ >>> for name,points in [('Homework 1', 3), ('Exam 1', 10)]:
+ ... assignment = Assignment(name=name, points=points, weight=0.5)
+ ... grade = Grade(
+ ... student=student, assignment=assignment,
+ ... points=int(points/2.0))
+ ... grades.append(grade)
+ >>> assignments = [g.assignment for g in grades]
+ >>> course = Course(
+ ... assignments=assignments, people=[student], grades=grades)
+ >>> msg = construct_course_email(
+ ... author=author, course=course, targets=[prof])
+ >>> print(msg.as_string())
+ ... # doctest: +REPORT_UDIFF, +ELLIPSIS, +NORMALIZE_WHITESPACE
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ Date: ...
+ From: Jack <a@b.net>
+ Reply-to: Jack <a@b.net>
+ To: "H.D." <hd@wall.net>
+ Subject: Course grades
+ <BLANKLINE>
+ H.D.,
+ <BLANKLINE>
+ Here are the (tab delimited) course grades to date:
+ <BLANKLINE>
+ Student\tExam 1\tHomework 1\tTotal
+ Jill\t5\t1\t0.416...
+ --
+ Mean\t5.00\t1.00\t0.416...
+ Std. Dev.\t0.00\t0.00\t0.0
+ <BLANKLINE>
+ The available points (and weights) for each assignment are:
+ * Exam 1:\t10\t0.5
+ * Homework 1:\t3\t0.5
+ <BLANKLINE>
+ Yours,
+ Jack
+ """
+ target = join_with_and([t.alias() for t in targets])
+ table = _io.StringIO()
+ _tabulate(course=course, statistics=True, stream=table)
+ return _construct_email(
+ author=author, targets=targets, cc=cc,
+ subject='Course grades',
+ text=COURSE_TEMPLATE.render(
+ author=author, course=course, target=target,
+ table=table.getvalue()))
--- /dev/null
+# Copyright
+
+"""List grading that still needs to be done.
+"""
+
+import os as _os
+import os.path as _os_path
+
+
+def mtime(path, walk_directories=True):
+ if walk_directories and _os.path.isdir(path):
+ time = mtime(path, walk_directories=False)
+ for dirpath,dirnames,filenames in _os.walk(path):
+ for filename in filenames:
+ t = mtime(_os_path.join(dirpath, filename))
+ time = max(time, t)
+ return time
+ stat = _os.stat(path)
+ return stat.st_mtime
+
+def newer(a, b):
+ """Return ``True`` if ``a`` is newer than ``b``.
+ """
+ return mtime(a) > mtime(b)
+
+def todo(basedir, source, target):
+ """Yield ``source``\s in ``basedir`` with old/missing ``target``\s.
+ """
+ for dirpath,dirnames,filenames in _os.walk(basedir):
+ names = dirnames + filenames
+ if source in names:
+ s = _os_path.join(dirpath, source)
+ t = _os_path.join(dirpath, target)
+ if target in names:
+ if newer(s, t):
+ yield(s)
+ else:
+ yield s
+
+def print_todo(basedir, source, target):
+ """Print ``source``\s in ``basedir`` with old/missing ``target``\s.
+ """
+ for path in sorted(todo(basedir, source, target)):
+ print(path)
--- /dev/null
+# Copyright
+
+"Manage a course's grade database with email-based communication."
+
+from distutils.core import setup as _setup
+import os.path as _os_path
+
+from pygrader import __version__
+
+
+_this_dir = _os_path.dirname(__file__)
+
+_setup(
+ name='pygrader',
+ version=__version__,
+ maintainer='W. Trevor King',
+ maintainer_email='wking@drexel.edu',
+ url='http://blog.tremily.us/posts/pygrader/',
+ download_url='http://git.tremily.us/?p=pygrader.git;a=snapshot;h=v{};sf=tgz'.format(__version__),
+ license = 'GNU General Public License (GPL)',
+ platforms = ['all'],
+ description = __doc__,
+ long_description=open(_os_path.join(_this_dir, 'README'), 'r').read(),
+ classifiers = [
+ 'Development Status :: 3 - Alpha',
+ 'Intended Audience :: Education',
+ 'Operating System :: OS Independent',
+ 'License :: OSI Approved :: GNU General Public License (GPL)',
+ 'Programming Language :: Python :: 3',
+ 'Topic :: Communications :: Email',
+ 'Topic :: Database',
+ 'Topic :: Education',
+ ],
+ scripts = ['bin/pg.py'],
+ packages = ['pygrader', 'pygrade.model'],
+ provides = ['pygrader', 'pygrade.model'],
+ )
--- /dev/null
+3
+
+Violence is not the answer.
--- /dev/null
+8
+
+You need to work on your charitable giving.
--- /dev/null
+10
+
+Nice Job!
--- /dev/null
+[course]
+assignments: Attendance 1, Attendance 2, Attendance 3, Attendance 4,
+ Attendance 5, Attendance 6, Attendance 7, Attendance 8, Attendance 9,
+ Assignment 1, Assignment 2, Exam 1, Exam 2
+professors: Gandalf
+assistants: Sauron
+students: Bilbo Baggins, Frodo Baggins, Aragorn
+
+[Attendance 1]
+points: 1
+weight: 0.1/9
+due: 2011-10-03
+
+[Attendance 2]
+points: 1
+weight: 0.1/9
+due: 2011-10-04
+
+[Attendance 3]
+points: 1
+weight: 0.1/9
+due: 2011-10-05
+
+[Attendance 4]
+points: 1
+weight: 0.1/9
+due: 2011-10-06
+
+[Attendance 5]
+points: 1
+weight: 0.1/9
+due: 2011-10-11
+
+[Attendance 6]
+points: 1
+weight: 0.1/9
+due: 2011-10-12
+
+[Attendance 7]
+points: 1
+weight: 0.1/9
+due: 2011-10-13
+
+[Attendance 8]
+points: 1
+weight: 0.1/9
+due: 2011-10-14
+
+[Attendance 9]
+points: 1
+weight: 0.1/9
+due: 2011-10-15
+
+[Assignment 1]
+points: 10
+weight: 0.4/2
+due: 2011-10-10
+
+[Assignment 2]
+points: 1
+weight: 0.4/2
+due: 2011-10-17
+
+[Exam 1]
+points: 10
+weight: 0.4/2
+due: 2011-10-10
+
+[Exam 2]
+points: 10
+weight: 0.4/2
+due: 2011-10-17
+
+[Gandalf]
+nickname: G-Man
+emails: g@grey.edu
+pgp-key: 0x0123456789ABCDEF
+
+[Sauron]
+nickname: Saury
+emails: eye@tower.edu
+pgp-key: 4332B6E3
+
+[Bilbo Baggins]
+nickname: Billy
+emails: bb@shire.org, bb@greyhavens.net
+
+[Frodo Baggins]
+nickname: Frodo
+emails: fb@shire.org
+
+[Aragorn]
+emails: a@awesome.gov