Skip to content

Commit 3a66dc9

Browse files
author
Kareem Zidane
authoredMay 27, 2017
Merge pull request #22 from cs50/develop
2.0.0
2 parents 4c3a02e + da85752 commit 3a66dc9

13 files changed

+434
-215
lines changed
 

‎.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
!.gitignore
33
!.travis.yml
44
dist/
5+
*.db
56
*.egg-info/
7+
*.pyc

‎.travis.yml

+30-42
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,32 @@
11
language: python
2-
3-
python: "3.4"
4-
5-
# build sdist
6-
script: make build
7-
8-
# install twine for uploading to PyPI
9-
before_deploy: pip install twine
10-
2+
python:
3+
- '2.7'
4+
- '3.4'
5+
branches:
6+
except: "/^v\\d/"
7+
services:
8+
- mysql
9+
- postgresql
10+
install:
11+
- python setup.py install
12+
- pip install mysqlclient
13+
- pip install psycopg2
14+
before_script:
15+
- mysql -e 'CREATE DATABASE IF NOT EXISTS test;'
16+
- psql -c 'create database test;' -U postgres
17+
script: python tests/sqltests.py
1118
deploy:
12-
13-
# create github release
14-
- provider: releases
15-
16-
# GitHub access token
17-
api_key:
18-
secure: "pwBn1lky58GHp1qR4i0oSZyOJkGMTvRzt0EfESVtl2ZbRTVUE7UFQbk/cL1002zMOUkJ4c5IjRCg95NJZWUvOSHwt4cOyqDVzi/S6AqACOlOhfnkw3S6oLGgRT3IlXK2ng0Viiqc1+BlVdwwXURKyobirqFgr4MAlb5kh75WmV9Xs4GwIS+qPq9luv38Bls2US/mNt5KRV1DiePr2ZSCqFESFfoIz+QKhZtVdynEF4jJwevEwP4HrCoT3guIJlXcWhOG37n+e8S4YLwg+k3yYeQTmR/QMgjmQLwEBZ6v9bNjqXM3CMtn3KUryDzcp5Z5+Vv1p1uoDbmuK+Ll5nQttAp/gARk+IWZ/xWc8MuQpFvjRzafbtPiF7ZlqaYh1wCREuZTWDAk/UJgQxb81v0jo0iAPyk9HMfgK2CJuU8wDwraKZ5dKk4y45Zww1gSSzpJJ8xSrylKPn7Wnft617Lnc+O0X6DnIAFtHDAPu/lPFaaokn1TN9AOPXoxb2cEeh+oDcUQD4zZG6Ukvh9+Hw8XiFBG+jEm6ekCvawTjnlZmBIw8YPJKEjrZv8LWfKhnVebRbmehawmnrZxUALCp39EjrcsIltYw4gefbd/Z9kIr8r3yVZfuq7U6vd8PBuCiDZHlKM1Lz4Ns24WK96nYe6V9Lt3WUERh6xt8JtuFrNHBiQ="
19-
20-
# enable wildcards in filenames
21-
file_glob: true
22-
23-
# upload sdist
24-
file: dist/*
25-
26-
# avoid stashing sdist
27-
skip_cleanup: true
28-
29-
# create releases on tags only
30-
on:
31-
tags: true
32-
33-
# deploy to PyPI
34-
- provider: script
35-
36-
# upload sdist to PyPI
37-
script: twine upload -u $PYPI_USERNAME -p $PYPI_PASSWORD dist/*
38-
39-
# avoid stashing sdist
40-
skip_cleanup: true
41-
42-
# deploy on tags only
43-
on:
44-
tags: true
19+
- provider: script
20+
script: 'curl --fail --data "{ \"tag_name\": \"v$(python setup.py --version)\",
21+
\"target_commitish\": \"$TRAVIS_COMMIT\", \"name\": \"v$(python setup.py --version)\"
22+
}" --user bot50:$GITHUB_TOKEN https://api.github.com/repos/$TRAVIS_REPO_SLUG/releases'
23+
on:
24+
branch: master
25+
- provider: pypi
26+
user: "$PYPI_USERNAME"
27+
password: "$PYPI_PASSWORD"
28+
on:
29+
branch: master
30+
notifications:
31+
slack:
32+
secure: lJklhcBVjDT6KzUNa3RFHXdXSeH7ytuuGrkZ5ZcR72CXMoTf2pMJTzPwRLWOp6lCSdDC9Y8MWLrcg/e33dJga4Jlp9alOmWqeqesaFjfee4st8vAsgNbv8/RajPH1gD2bnkt8oIwUzdHItdb5AucKFYjbH2g0d8ndoqYqUeBLrnsT1AP5G/Vi9OHC9OWNpR0FKaZIJE0Wt52vkPMH3sV2mFeIskByPB+56U5y547mualKxn61IVR/dhYBEtZQJuSvnwKHPOn9Pkk7cCa+SSSeTJ4w5LboY8T17otaYNauXo46i1bKIoGiBcCcrJyQHHiPQmcq/YU540MC5Wzt9YXUycmJzRi347oyQeDee27wV3XJlWMXuuhbtJiKCFny7BTQ160VATlj/dbwIzN99Ra6/BtTumv/6LyTdKIuVjdAkcN8dtdDW1nlrQ29zuPNCcXXzJ7zX7kQaOCUV1c2OrsbiH/0fE9nknUORn97txqhlYVi0QMS7764wFo6kg0vpmFQRkkQySsJl+TmgcZ01AlsJc2EMMWVuaj9Af9JU4/4yalqDiXIh1fOYYUZnLfOfWS+MsnI+/oLfqJFyMbrsQQTIjs+kTzbiEdhd2R4EZgusU/xRFWokS2NAvahexrRhRQ6tpAI+LezPrkNOR3aHiykBf+P9BkUa0wPp6V2Ayc6q0=

‎Makefile

-22
This file was deleted.

‎README.md

+3-13
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,11 @@
44

55
Supports Python 2 and Python 3.
66

7-
## Development
8-
9-
Requires [Docker Engine](https://docs.docker.com/engine/installation/).
10-
11-
make bash
12-
make deb # builds .deb
13-
147
## Installation
158

16-
1. Download the latest release per https://github.com/cs50/python-cs50/releases
17-
1. Extract `python-cs50-*`
18-
1. `cd python-cs50-*`
19-
1. `make install`
9+
```
10+
pip install cs50
11+
```
2012

2113
## Usage
2214

@@ -31,8 +23,6 @@ Requires [Docker Engine](https://docs.docker.com/engine/installation/).
3123
s = cs50.get_string();
3224

3325
## TODO
34-
35-
* Add install target to Makefile.
3626
* Conditionally install for Python 2 and/or Python 3.
3727
* Add targets for `pacman`, `rpm`.
3828
* Add tests.

‎cs50/cs50.py

-81
This file was deleted.

‎cs50/sql.py

-52
This file was deleted.

‎setup.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
install_requires=["SQLAlchemy"],
1414
keywords="cs50",
1515
name="cs50",
16+
package_dir={"": "src"},
1617
packages=["cs50"],
1718
url="https://github.com/cs50/python-cs50",
18-
version="1.3.0"
19+
version="2.0.0"
1920
)
File renamed without changes.

‎src/cs50/cs50.py

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from __future__ import print_function
2+
import inspect
3+
import re
4+
import sys
5+
6+
class flushfile():
7+
"""
8+
Disable buffering for standard output and standard error.
9+
10+
http://stackoverflow.com/a/231216
11+
"""
12+
def __init__(self, f):
13+
self.f = f
14+
15+
def __getattr__(self, name):
16+
return object.__getattribute__(self.f, name)
17+
18+
def write(self, x):
19+
self.f.write(x)
20+
self.f.flush()
21+
sys.stderr = flushfile(sys.stderr)
22+
sys.stdout = flushfile(sys.stdout)
23+
24+
def eprint(*args, **kwargs):
25+
"""
26+
Print an error message to standard error, prefixing it with
27+
file name and line number from which method was called.
28+
"""
29+
end = kwargs.get("end", "\n")
30+
sep = kwargs.get("sep", " ")
31+
(filename, lineno) = inspect.stack()[1][1:3]
32+
print("{}:{}: ".format(filename, lineno), end="")
33+
print(*args, end=end, file=sys.stderr, sep=sep)
34+
35+
def get_char(prompt=None):
36+
"""
37+
Read a line of text from standard input and return the equivalent char;
38+
if text is not a single char, user is prompted to retry. If line can't
39+
be read, return None.
40+
"""
41+
while True:
42+
s = get_string(prompt)
43+
if s is None:
44+
return None
45+
if len(s) == 1:
46+
return s[0]
47+
48+
# temporarily here for backwards compatibility
49+
if prompt is None:
50+
print("Retry: ", end="")
51+
52+
def get_float(prompt=None):
53+
"""
54+
Read a line of text from standard input and return the equivalent float
55+
as precisely as possible; if text does not represent a double, user is
56+
prompted to retry. If line can't be read, return None.
57+
"""
58+
while True:
59+
s = get_string(prompt)
60+
if s is None:
61+
return None
62+
if len(s) > 0 and re.search(r"^[+-]?\d*(?:\.\d*)?$", s):
63+
try:
64+
return float(s)
65+
except ValueError:
66+
pass
67+
68+
# temporarily here for backwards compatibility
69+
if prompt is None:
70+
print("Retry: ", end="")
71+
72+
def get_int(prompt=None):
73+
"""
74+
Read a line of text from standard input and return the equivalent int;
75+
if text does not represent an int, user is prompted to retry. If line
76+
can't be read, return None.
77+
"""
78+
while True:
79+
s = get_string(prompt);
80+
if s is None:
81+
return None
82+
if re.search(r"^[+-]?\d+$", s):
83+
try:
84+
i = int(s, 10)
85+
if type(i) is int: # could become long in Python 2
86+
return i
87+
except ValueError:
88+
pass
89+
90+
# temporarily here for backwards compatibility
91+
if prompt is None:
92+
print("Retry: ", end="")
93+
94+
if sys.version_info.major != 3:
95+
def get_long(prompt=None):
96+
"""
97+
Read a line of text from standard input and return the equivalent long;
98+
if text does not represent a long, user is prompted to retry. If line
99+
can't be read, return None.
100+
"""
101+
while True:
102+
s = get_string(prompt)
103+
if s is None:
104+
return None
105+
if re.search(r"^[+-]?\d+$", s):
106+
try:
107+
return long(s, 10)
108+
except ValueError:
109+
pass
110+
111+
# temporarily here for backwards compatibility
112+
if prompt is None:
113+
print("Retry: ", end="")
114+
115+
def get_string(prompt=None):
116+
"""
117+
Read a line of text from standard input and return it as a string,
118+
sans trailing line ending. Supports CR (\r), LF (\n), and CRLF (\r\n)
119+
as line endings. If user inputs only a line ending, returns "", not None.
120+
Returns None upon error or no input whatsoever (i.e., just EOF).
121+
"""
122+
try:
123+
if prompt is not None:
124+
print(prompt, end="")
125+
s = sys.stdin.readline()
126+
if not s:
127+
return None
128+
return re.sub(r"(?:\r|\r\n|\n)$", "", s)
129+
except ValueError:
130+
return None

‎src/cs50/sql.py

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import datetime
2+
import importlib
3+
import logging
4+
import re
5+
import sqlalchemy
6+
import sys
7+
import warnings
8+
9+
class SQL(object):
10+
"""Wrap SQLAlchemy to provide a simple SQL API."""
11+
12+
def __init__(self, url, **kwargs):
13+
"""
14+
Create instance of sqlalchemy.engine.Engine.
15+
16+
URL should be a string that indicates database dialect and connection arguments.
17+
18+
http://docs.sqlalchemy.org/en/latest/core/engines.html#sqlalchemy.create_engine
19+
http://docs.sqlalchemy.org/en/latest/dialects/index.html
20+
"""
21+
22+
# log statements to standard error
23+
logging.basicConfig(level=logging.DEBUG)
24+
self.logger = logging.getLogger(__name__)
25+
26+
# create engine, raising exception if back end's module not installed
27+
self.engine = sqlalchemy.create_engine(url, **kwargs)
28+
29+
def execute(self, text, **params):
30+
"""
31+
Execute a SQL statement.
32+
"""
33+
34+
class UserDefinedType(sqlalchemy.TypeDecorator):
35+
"""
36+
Add support for expandable values, a la https://bitbucket.org/zzzeek/sqlalchemy/issues/3953/expanding-parameter.
37+
"""
38+
impl = sqlalchemy.types.UserDefinedType
39+
def process_literal_param(self, value, dialect):
40+
"""Receive a literal parameter value to be rendered inline within a statement."""
41+
def process(value):
42+
"""Render a literal value, escaping as needed."""
43+
44+
# bool
45+
if isinstance(value, bool):
46+
return sqlalchemy.types.Boolean().literal_processor(dialect)(value)
47+
48+
# datetime.date
49+
elif isinstance(value, datetime.date):
50+
return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%Y-%m-%d"))
51+
52+
# datetime.datetime
53+
elif isinstance(value, datetime.datetime):
54+
return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%Y-%m-%d %H:%M:%S"))
55+
56+
# datetime.time
57+
elif isinstance(value, datetime.time):
58+
return sqlalchemy.types.String().literal_processor(dialect)(value.strftime("%H:%M:%S"))
59+
60+
# float
61+
elif isinstance(value, float):
62+
return sqlalchemy.types.Float().literal_processor(dialect)(value)
63+
64+
# int
65+
elif isinstance(value, int):
66+
return sqlalchemy.types.Integer().literal_processor(dialect)(value)
67+
68+
# long
69+
elif sys.version_info.major != 3 and isinstance(value, long):
70+
return sqlalchemy.types.Integer().literal_processor(dialect)(value)
71+
72+
# str
73+
elif isinstance(value, str):
74+
return sqlalchemy.types.String().literal_processor(dialect)(value)
75+
76+
# None
77+
elif isinstance(value, sqlalchemy.sql.elements.Null):
78+
return sqlalchemy.types.NullType().literal_processor(dialect)(value)
79+
80+
# unsupported value
81+
raise RuntimeError("unsupported value")
82+
83+
# process value(s), separating with commas as needed
84+
if type(value) is list:
85+
return ", ".join([process(v) for v in value])
86+
else:
87+
return process(value)
88+
89+
# raise exceptions for warnings
90+
warnings.filterwarnings("error")
91+
92+
# prepare, execute statement
93+
try:
94+
95+
# construct a new TextClause clause
96+
statement = sqlalchemy.text(text)
97+
98+
# iterate over parameters
99+
for key, value in params.items():
100+
101+
# translate None to NULL
102+
if value is None:
103+
value = sqlalchemy.sql.null()
104+
105+
# bind parameters before statement reaches database, so that bound parameters appear in exceptions
106+
# http://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.expression.text
107+
statement = statement.bindparams(sqlalchemy.bindparam(key, value=value, type_=UserDefinedType()))
108+
109+
# stringify bound parameters
110+
# http://docs.sqlalchemy.org/en/latest/faq/sqlexpressions.html#how-do-i-render-sql-expressions-as-strings-possibly-with-bound-parameters-inlined
111+
statement = str(statement.compile(compile_kwargs={"literal_binds": True}))
112+
113+
# execute statement
114+
result = self.engine.execute(statement)
115+
116+
# log statement
117+
self.logger.debug(statement)
118+
119+
# if SELECT (or INSERT with RETURNING), return result set as list of dict objects
120+
if re.search(r"^\s*SELECT\s+", statement, re.I):
121+
rows = result.fetchall()
122+
return [dict(row) for row in rows]
123+
124+
# if INSERT, return primary key value for a newly inserted row
125+
elif re.search(r"^\s*INSERT\s+", statement, re.I):
126+
if self.engine.url.get_backend_name() == "postgresql":
127+
result = self.engine.execute(sqlalchemy.text("SELECT LASTVAL()"))
128+
return result.first()[0]
129+
else:
130+
return result.lastrowid
131+
132+
# if DELETE or UPDATE, return number of rows matched
133+
elif re.search(r"^\s*(?:DELETE|UPDATE)\s+", statement, re.I):
134+
return result.rowcount
135+
136+
# if some other statement, return True unless exception
137+
return True
138+
139+
# if constraint violated, return None
140+
except sqlalchemy.exc.IntegrityError:
141+
return None

‎test/python2.py ‎tests/python2.py

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import cs50
22

3-
from cs50 import SQL
4-
53
l = cs50.get_long()
64
print(l)

‎test/python3.py ‎tests/python3.py

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import cs50
2-
from cs50 import SQL
32

43
i = cs50.get_int()
54
print(i)
6-

‎tests/sqltests.py

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from cs50.sql import SQL
2+
import sys
3+
import unittest
4+
import warnings
5+
6+
class SQLTests(unittest.TestCase):
7+
def multi_inserts_enabled(self):
8+
return True
9+
10+
def test_delete_returns_affected_rows(self):
11+
rows = [
12+
{"id": 1, "val": "foo"},
13+
{"id": 2, "val": "bar"},
14+
{"id": 3, "val": "baz"}
15+
]
16+
for row in rows:
17+
self.db.execute("INSERT INTO cs50(val) VALUES(:val);", val=row["val"])
18+
19+
print(self.db.execute("DELETE FROM cs50 WHERE id = :id", id=rows[0]["id"]))
20+
print(self.db.execute("SELECT * FROM cs50"))
21+
return
22+
23+
self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = :id", id=rows[0]["id"]), 1)
24+
self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = :a or id = :b", a=rows[1]["id"], b=rows[2]["id"]), 2)
25+
self.assertEqual(self.db.execute("DELETE FROM cs50 WHERE id = -50"), 0)
26+
27+
def test_insert_returns_last_row_id(self):
28+
self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('foo')"), 1)
29+
self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('bar')"), 2)
30+
if self.multi_inserts_enabled():
31+
self.assertEqual(self.db.execute("INSERT INTO cs50(val) VALUES('baz'); INSERT INTO cs50(val) VALUES('qux')"), 4)
32+
33+
def test_select_all(self):
34+
self.assertEqual(self.db.execute("SELECT * FROM cs50"), [])
35+
36+
rows = [
37+
{"id": 1, "val": "foo"},
38+
{"id": 2, "val": "bar"},
39+
{"id": 3, "val": "baz"}
40+
]
41+
for row in rows:
42+
self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])
43+
44+
self.assertEqual(self.db.execute("SELECT * FROM cs50"), rows)
45+
46+
def test_select_cols(self):
47+
rows = [
48+
{"val": "foo"},
49+
{"val": "bar"},
50+
{"val": "baz"}
51+
]
52+
for row in rows:
53+
self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])
54+
55+
self.assertEqual(self.db.execute("SELECT val FROM cs50"), rows)
56+
57+
def test_select_where(self):
58+
rows = [
59+
{"id": 1, "val": "foo"},
60+
{"id": 2, "val": "bar"},
61+
{"id": 3, "val": "baz"}
62+
]
63+
for row in rows:
64+
self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])
65+
66+
self.assertEqual(self.db.execute("SELECT * FROM cs50 WHERE id = :id OR val = :val", id=rows[1]["id"], val=rows[2]["val"]), rows[1:3])
67+
68+
def test_update_returns_affected_rows(self):
69+
rows = [
70+
{"id": 1, "val": "foo"},
71+
{"id": 2, "val": "bar"},
72+
{"id": 3, "val": "baz"}
73+
]
74+
for row in rows:
75+
self.db.execute("INSERT INTO cs50(val) VALUES(:val)", val=row["val"])
76+
77+
self.assertEqual(self.db.execute("UPDATE cs50 SET val = 'foo' WHERE id > 1"), 2)
78+
self.assertEqual(self.db.execute("UPDATE cs50 SET val = 'foo' WHERE id = -50"), 0)
79+
80+
def tearDown(self):
81+
self.db.execute("DROP TABLE cs50")
82+
83+
@classmethod
84+
def tearDownClass(self):
85+
try:
86+
self.db.execute("DROP TABLE IF EXISTS cs50")
87+
except Warning as e:
88+
# suppress "unknown table"
89+
if not str(e).startswith("(1051"):
90+
raise e
91+
92+
class MySQLTests(SQLTests):
93+
@classmethod
94+
def setUpClass(self):
95+
self.db = SQL("mysql://root@localhost/test")
96+
97+
def setUp(self):
98+
self.db.execute("CREATE TABLE cs50 (id INTEGER NOT NULL AUTO_INCREMENT, val VARCHAR(16), PRIMARY KEY (id))")
99+
100+
class PostgresTests(SQLTests):
101+
@classmethod
102+
def setUpClass(self):
103+
self.db = SQL("postgresql://postgres@localhost/test")
104+
105+
def setUp(self):
106+
self.db.execute("CREATE TABLE cs50 (id SERIAL PRIMARY KEY, val VARCHAR(16))")
107+
108+
class SQLiteTests(SQLTests):
109+
@classmethod
110+
def setUpClass(self):
111+
self.db = SQL("sqlite:///test.db")
112+
113+
def setUp(self):
114+
self.db.execute("CREATE TABLE cs50(id INTEGER PRIMARY KEY, val TEXT)")
115+
116+
def multi_inserts_enabled(self):
117+
return False
118+
119+
if __name__ == "__main__":
120+
suite = unittest.TestSuite([
121+
unittest.TestLoader().loadTestsFromTestCase(SQLiteTests),
122+
unittest.TestLoader().loadTestsFromTestCase(MySQLTests),
123+
unittest.TestLoader().loadTestsFromTestCase(PostgresTests)
124+
])
125+
126+
sys.exit(not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful())

0 commit comments

Comments
 (0)
Please sign in to comment.