Begin versioning! (better late than never)
authorW. Trevor King <wking@drexel.edu>
Fri, 23 Mar 2012 19:18:48 +0000 (15:18 -0400)
committerW. Trevor King <wking@drexel.edu>
Fri, 23 Mar 2012 19:19:46 +0000 (15:19 -0400)
45 files changed:
.gitignore [new file with mode: 0644]
.update-copyright.conf [new file with mode: 0644]
COPYING [new file with mode: 0644]
README [new file with mode: 0644]
bin/pg.py [new file with mode: 0755]
pygrader/__init__.py [new file with mode: 0644]
pygrader/color.py [new file with mode: 0644]
pygrader/email.py [new file with mode: 0644]
pygrader/extract_mime.py [new file with mode: 0644]
pygrader/mailpipe.py [new file with mode: 0644]
pygrader/model/__init__.py [new file with mode: 0644]
pygrader/model/assignment.py [new file with mode: 0644]
pygrader/model/course.py [new file with mode: 0644]
pygrader/model/grade.py [new file with mode: 0644]
pygrader/model/person.py [new file with mode: 0644]
pygrader/storage.py [new file with mode: 0644]
pygrader/tabulate.py [new file with mode: 0644]
pygrader/template.py [new file with mode: 0644]
pygrader/todo.py [new file with mode: 0644]
setup.py [new file with mode: 0644]
test/Aragorn/Assignment_1/grade [new file with mode: 0644]
test/Aragorn/Attendance_1/grade [new file with mode: 0644]
test/Aragorn/Attendance_2/grade [new file with mode: 0644]
test/Aragorn/Attendance_3/grade [new file with mode: 0644]
test/Aragorn/Attendance_4/grade [new file with mode: 0644]
test/Aragorn/Attendance_5/grade [new file with mode: 0644]
test/Aragorn/Attendance_6/grade [new file with mode: 0644]
test/Aragorn/Attendance_7/grade [new file with mode: 0644]
test/Aragorn/Exam_1/grade [new file with mode: 0644]
test/Bilbo_Baggins/Assignment_1/grade [new file with mode: 0644]
test/Bilbo_Baggins/Attendance_1/grade [new file with mode: 0644]
test/Bilbo_Baggins/Attendance_2/grade [new file with mode: 0644]
test/Bilbo_Baggins/Attendance_6/grade [new file with mode: 0644]
test/Bilbo_Baggins/Attendance_7/grade [new file with mode: 0644]
test/Bilbo_Baggins/Exam_1/grade [new file with mode: 0644]
test/Frodo_Baggins/Assignment_1/grade [new file with mode: 0644]
test/Frodo_Baggins/Attendance_1/grade [new file with mode: 0644]
test/Frodo_Baggins/Attendance_2/grade [new file with mode: 0644]
test/Frodo_Baggins/Attendance_3/grade [new file with mode: 0644]
test/Frodo_Baggins/Attendance_4/grade [new file with mode: 0644]
test/Frodo_Baggins/Attendance_5/grade [new file with mode: 0644]
test/Frodo_Baggins/Attendance_6/grade [new file with mode: 0644]
test/Frodo_Baggins/Attendance_7/grade [new file with mode: 0644]
test/Frodo_Baggins/Exam_1/grade [new file with mode: 0644]
test/course.conf [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..27ffc2f
--- /dev/null
@@ -0,0 +1,2 @@
+*.pyc
+build
diff --git a/.update-copyright.conf b/.update-copyright.conf
new file mode 100644 (file)
index 0000000..ceac746
--- /dev/null
@@ -0,0 +1,18 @@
+[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/>.
diff --git a/COPYING b/COPYING
new file mode 100644 (file)
index 0000000..94a9ed0
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,674 @@
+                    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>.
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..43101f6
--- /dev/null
+++ b/README
@@ -0,0 +1,203 @@
+``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/
diff --git a/bin/pg.py b/bin/pg.py
new file mode 100755 (executable)
index 0000000..62a1041
--- /dev/null
+++ b/bin/pg.py
@@ -0,0 +1,211 @@
+#!/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)
diff --git a/pygrader/__init__.py b/pygrader/__init__.py
new file mode 100644 (file)
index 0000000..2dc4e28
--- /dev/null
@@ -0,0 +1,11 @@
+# Copyright
+
+import logging as _logging
+
+__version__ = '0.1'
+ENCODING = 'utf-8'
+
+
+LOG = _logging.getLogger('pygrade')
+LOG.setLevel(_logging.ERROR)
+LOG.addHandler(_logging.StreamHandler())
diff --git a/pygrader/color.py b/pygrader/color.py
new file mode 100644 (file)
index 0000000..741fd34
--- /dev/null
@@ -0,0 +1,85 @@
+# 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()
diff --git a/pygrader/email.py b/pygrader/email.py
new file mode 100644 (file)
index 0000000..d1f0ef0
--- /dev/null
@@ -0,0 +1,213 @@
+# -*- 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
diff --git a/pygrader/extract_mime.py b/pygrader/extract_mime.py
new file mode 100644 (file)
index 0000000..97d6483
--- /dev/null
@@ -0,0 +1,61 @@
+# 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))
diff --git a/pygrader/mailpipe.py b/pygrader/mailpipe.py
new file mode 100644 (file)
index 0000000..87562b9
--- /dev/null
@@ -0,0 +1,265 @@
+# 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
diff --git a/pygrader/model/__init__.py b/pygrader/model/__init__.py
new file mode 100644 (file)
index 0000000..b98f164
--- /dev/null
@@ -0,0 +1 @@
+# Copyright
diff --git a/pygrader/model/assignment.py b/pygrader/model/assignment.py
new file mode 100644 (file)
index 0000000..e2d8d6a
--- /dev/null
@@ -0,0 +1,18 @@
+# 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
diff --git a/pygrader/model/course.py b/pygrader/model/course.py
new file mode 100644 (file)
index 0000000..1d56ac0
--- /dev/null
@@ -0,0 +1,112 @@
+# 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
diff --git a/pygrader/model/grade.py b/pygrader/model/grade.py
new file mode 100644 (file)
index 0000000..7662cff
--- /dev/null
@@ -0,0 +1,22 @@
+# 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
diff --git a/pygrader/model/person.py b/pygrader/model/person.py
new file mode 100644 (file)
index 0000000..f3855fc
--- /dev/null
@@ -0,0 +1,26 @@
+# 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
diff --git a/pygrader/storage.py b/pygrader/storage.py
new file mode 100644 (file)
index 0000000..4033cb1
--- /dev/null
@@ -0,0 +1,297 @@
+# 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))
diff --git a/pygrader/tabulate.py b/pygrader/tabulate.py
new file mode 100644 (file)
index 0000000..e074801
--- /dev/null
@@ -0,0 +1,78 @@
+# 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)
diff --git a/pygrader/template.py b/pygrader/template.py
new file mode 100644 (file)
index 0000000..4ec585c
--- /dev/null
@@ -0,0 +1,379 @@
+# 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()))
diff --git a/pygrader/todo.py b/pygrader/todo.py
new file mode 100644 (file)
index 0000000..827b68a
--- /dev/null
@@ -0,0 +1,44 @@
+# 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)
diff --git a/setup.py b/setup.py
new file mode 100644 (file)
index 0000000..dcf9ffc
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,37 @@
+# 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'],
+    )
diff --git a/test/Aragorn/Assignment_1/grade b/test/Aragorn/Assignment_1/grade
new file mode 100644 (file)
index 0000000..45a4fb7
--- /dev/null
@@ -0,0 +1 @@
+8
diff --git a/test/Aragorn/Attendance_1/grade b/test/Aragorn/Attendance_1/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Aragorn/Attendance_2/grade b/test/Aragorn/Attendance_2/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Aragorn/Attendance_3/grade b/test/Aragorn/Attendance_3/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Aragorn/Attendance_4/grade b/test/Aragorn/Attendance_4/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Aragorn/Attendance_5/grade b/test/Aragorn/Attendance_5/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Aragorn/Attendance_6/grade b/test/Aragorn/Attendance_6/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Aragorn/Attendance_7/grade b/test/Aragorn/Attendance_7/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Aragorn/Exam_1/grade b/test/Aragorn/Exam_1/grade
new file mode 100644 (file)
index 0000000..09a4fdc
--- /dev/null
@@ -0,0 +1,3 @@
+3
+
+Violence is not the answer.
diff --git a/test/Bilbo_Baggins/Assignment_1/grade b/test/Bilbo_Baggins/Assignment_1/grade
new file mode 100644 (file)
index 0000000..45a4fb7
--- /dev/null
@@ -0,0 +1 @@
+8
diff --git a/test/Bilbo_Baggins/Attendance_1/grade b/test/Bilbo_Baggins/Attendance_1/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Bilbo_Baggins/Attendance_2/grade b/test/Bilbo_Baggins/Attendance_2/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Bilbo_Baggins/Attendance_6/grade b/test/Bilbo_Baggins/Attendance_6/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Bilbo_Baggins/Attendance_7/grade b/test/Bilbo_Baggins/Attendance_7/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Bilbo_Baggins/Exam_1/grade b/test/Bilbo_Baggins/Exam_1/grade
new file mode 100644 (file)
index 0000000..770a4fd
--- /dev/null
@@ -0,0 +1,3 @@
+8
+
+You need to work on your charitable giving.
diff --git a/test/Frodo_Baggins/Assignment_1/grade b/test/Frodo_Baggins/Assignment_1/grade
new file mode 100644 (file)
index 0000000..45a4fb7
--- /dev/null
@@ -0,0 +1 @@
+8
diff --git a/test/Frodo_Baggins/Attendance_1/grade b/test/Frodo_Baggins/Attendance_1/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Frodo_Baggins/Attendance_2/grade b/test/Frodo_Baggins/Attendance_2/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Frodo_Baggins/Attendance_3/grade b/test/Frodo_Baggins/Attendance_3/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Frodo_Baggins/Attendance_4/grade b/test/Frodo_Baggins/Attendance_4/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Frodo_Baggins/Attendance_5/grade b/test/Frodo_Baggins/Attendance_5/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Frodo_Baggins/Attendance_6/grade b/test/Frodo_Baggins/Attendance_6/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Frodo_Baggins/Attendance_7/grade b/test/Frodo_Baggins/Attendance_7/grade
new file mode 100644 (file)
index 0000000..d00491f
--- /dev/null
@@ -0,0 +1 @@
+1
diff --git a/test/Frodo_Baggins/Exam_1/grade b/test/Frodo_Baggins/Exam_1/grade
new file mode 100644 (file)
index 0000000..2dd574d
--- /dev/null
@@ -0,0 +1,3 @@
+10
+
+Nice Job!
diff --git a/test/course.conf b/test/course.conf
new file mode 100644 (file)
index 0000000..744e96c
--- /dev/null
@@ -0,0 +1,93 @@
+[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