Introduction To Python: Part 3

During this lecture, we’ll learn a bit about Python sequences, strings and dictionaries. We’ll also cover looping, both determinate and indeterminate. Finally, we’ll talk about how to interact with data from the file system.

Sequences

Python is both strongly typed and dynamically typed. This combination leads to an approach to programming we call “Duck Typing”. So long as an object behaves like the kind of thing we want, we can assume it is the kind of thing we want.

Sequences are a prime example of this type of thinking.

In Python, a sequence refers to an ordered collection of objects. To be counted as a sequence, the object should support at least the following operations:

  • Indexing
  • Slicing
  • Membership
  • Concatenation
  • Length
  • Iteration

There are a number of standard data types in Python that fulfill this contract.

Python 2 Python 3
byte string (str) byte string (bytes)
unicode string (unicode) unicode string (str)
list list
tuple tuple
bytearray bytearray
buffer memoryview
xrange object range object

Of these types, the ones you will most often use are the string types, lists and tuples. The others are largely crafted for special purposes and you will rarely see them. However, the operations we will discuss next apply to all of them (with a few caveats).

Indexing

We can look up an object from within a sequence using the subscription operator: []. We use the index (position) of the object in the sequence to look it up. In Python, indexing always starts at 0.

In [98]: s = u"this is a string"
In [99]: s[0]
Out[99]: u't'
In [100]: s[5]
Out[100]: u'i'

We can also pass a negative integer as the index. This returns the object n positions from the end of the sequence:

In [105]: s = u"this is a string"
In [106]: s[-1]
Out[106]: u'g'
In [107]: s[-6]
Out[107]: u's'

If you ask for an object by an index that is beyond the end of the sequence, this causes an IndexError:

In [4]: s = [0, 1, 2, 3]
In [5]: s[4]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-5-42efaba84d8b> in <module>()
----> 1 s[4]

IndexError: list index out of range

Slicing

Indexing returns one object from a sequence. To get a new sequence containing elements from the original, we use slicing. This also uses the subscription operator, but with a bit of a syntactic twist. We use one or more colons (:) to separate the three available arguments, start, stop, and step:

seq[start:stop:step]

In slicing, asking for seq[start:stop] will return a new sequence (of the same type) containing all the elements of the original where start <= index < stop.

In [121]: s = u"a bunch of words"
In [122]: s[2]
Out[122]: u'b'
In [123]: s[6]
Out[123]: u'h'
In [124]: s[2:6]
Out[124]: u'bunc'
In [125]: s[2:7]
Out[125]: u'bunch'

It can often be helpful in slicing to think of the index values as pointing to the spaces between the items in the sequence:

  a       b   u   n   c   h       o   f
|   |   |   |   |   |   |   |   |   |
0   1   2   3   4   5   6   7   8   9

So why do we start with zero? Why is the stop index in the slice not included? Because doing things this way leads to some very nice properties for slices:

len(seq[a:b]) == b-a

seq[:b] + seq[b:] == seq

len(seq[:b]) == b

len(seq[-b:]) == b

As a result of these properties, it’s easier to avoid off-by-one errors in Python.

The third argument to the slice operation is the step. It is used to control which items between start and stop are returned.

In [289]: string = u"a fairly long string"
In [290]: string[0:15]
Out[290]: u'a fairly long s'
In [291]: string[0:15:2]
Out[291]: u'afil ogs'
In [292]: string[0:15:3]
Out[292]: u'aallg'

Using a negative value for step can lead to a nifty way to reverse a sequence:

In [293]: string[::-1]
Out[293]: u'gnirts gnol ylriaf a'

As we’ve mentioned before, indexing a sequence returns a single object. Slicing returns a new sequence. There’s one other major difference between the two. Slicing past the end of a sequence does not cause an error:

In [129]: s = "a bunch of words"
In [130]: s[17]
----> 1 s[17]
IndexError: string index out of range
In [131]: s[10:20]
Out[131]: ' words'
In [132]: s[20:30]
Out[132]: "

Membership

Sequence types support using the membership operators: in (py3) and not in (py3). These allow us to test for the presence (or absence) of an object in a sequence.

In [15]: s = [1, 2, 3, 4, 5, 6]
In [16]: 5 in s
Out[16]: True
In [17]: 42 in s
Out[17]: False
In [18]: 42 not in s
Out[18]: True

When used with the string types, the membership operators behave like substring in other languages. Use them to test whether a string contains another, shorter string:

In [20]: s = u"This is a long string"
In [21]: u"long" in s
Out[21]: True

This is only true for the string-type sequences. Can you think of why that might be?

Concatenation

When used with sequences as operands, the + and * operators will concatenate sequences.

In [25]: s1 = u"left"
In [26]: s2 = u"right"
In [27]: s1 + s2
Out[27]: u'leftright'
In [28]: (s1 + s2) * 3
Out[28]: u'leftrightleftrightleftright'

Since slicing returns a new sequence, this applies to slices as well. This fact can allow for some very concise code.

For example (from CodingBat) lets assume you need to create a new string that contains three repetitions of a given string. But if the given string is longer than three characters, you only want to use the first three.

A not-particularly-Pythonic solution to the problem might look like this:

def front3(str):
  if len(str) < 3:
    return str+str+str
  else:
    return str[:3]+str[:3]+str[:3]

But the truly Pythonic programmer can express the same thing this way:

def front3(str):
    return str[:3] * 3

Length

Sequences have length. To get the length of a sequence we use the len builtin (py3).

In [36]: s = u"how long is this, anyway?"
In [37]: len(s)
Out[37]: 25

Because of zero-based indexing, you must remember that the last index in a sequence is always len(s) -1:

In [38]: count = len(s)
In [39]: s[len(s)]
------------------------------------------------------------
IndexError                Traceback (most recent call last)
<ipython-input-39-5a33b9d3e525> in <module>()
----> 1 s[count]
IndexError: string index out of range

But honestly, using that is not Pythonic anyway. Always use seq[-1] to find the last item in a sequence.

If you care (and some do) about why Python uses len(x) instead of x.length(), you can read this post with an explanation of the rationale from BDFL Guido Van Rossom.

Miscellaneous

There are a few other common operations (py3) on sequences you’ll want to know about.

The min (py3) and max (py3) builtins work as you might expect:

In [42]: all_letters = u"thequickbrownfoxjumpedoverthelazydog"
In [43]: min(all_letters)
Out[43]: u'a'
In [44]: max(all_letters)
Out[44]: u'z'

The index method returns the position of an object in a sequence. If the object is not in the sequence, this causes a ValueError:

In [46]: all_letters.index(u'd')
Out[46]: 21
In [47]: all_letters.index(u'A')
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-47-2db728a46f78> in <module>()
----> 1 all_letters.index(u'A')

ValueError: substring not found

Finally, the count method will count the total number of occurances of an object within a sequence. With strings, the object can be a single letter, or a substring. With the count method, if the object is not in the sequence, then no error is raised. The return value is 0:

In [52]: all_letters.count(u'o')
Out[52]: 4
In [53]: all_letters.count(u'the')
Out[53]: 2
In [54]: all_letters.count(u'A')
Out[54]: 0

Iteration

Repetition, Repetition, Repetition, Repe...

For Loops

We’ve already seen simple iteration over a sequence using for ... in:

In [170]: for x in "a string":
   .....:         print(x)
   .....:
a

s
t
r
i
n
g

Other languages build and use an index, which is then used to extract each item from the sequence:

for(var i=0; i<arr.length; i++) {
    var value = arr[i];
    console.log(i + ") " + value);

Python does not require this. But if you need to have the index for some reason, you can use the enumerate builtin (py3):

In [140]: for idx, letter in enumerate(u'Python'):
   .....:     print(idx, letter, end=' ')
   .....:
0 P 1 y 2 t 3 h 4 o 5 n

We’ve seen how the range function (it’s a type in Python3) can be useful for looping a known number of times. This is especially true when you don’t care about the value of the item from the sequence:

In [171]: for i in range(5):
   .....:     print('hello')
   .....:
hello
hello
hello
hello
hello

Remember that in Python, loops do not create a local namespace. The loop variable you use is still in scope after the loop terminates:

In [172]: x = 10
In [173]: for x in range(3):
   .....:     pass
   .....:
In [174]: x
Out[174]: 2

Loop control

Sometimes you want to interrupt or alter the flow of control through a loop. Loops can be controlled in two ways, with break and continue.

The break statement causes a loop to terminate immediately:

In [141]: for i in range(101):
   .....:     print(i)
   .....:     if i > 50:
   .....:         break
   .....:
0 1 2 3 4 5... 46 47 48 49 50 51

And continue returns you immediately to the head of the loop. It allows you to skip statements later in the loop block while continuing the loop itself:

In [143]: for i in range(101):
   .....:     if i > 50:
   .....:         break
   .....:     if i < 25:
   .....:         continue
   .....:     print(i, end=' ')
   .....:
   25 26 27 28 29 ... 41 42 43 44 45 46 47 48 49 50

An interesting feature of Python loops is that there is an optional else clause. The statements in this optional block are only executed if the loop exits normally. That means only if break was not used to stop iteration:

In [147]: for x in range(10):
   .....:     if x == 11:
   .....:         break
   .....: else:
   .....:     print(u'finished')
finished
In [148]: for x in range(10):
   .....:     if x == 5:
   .....:         print(x)
   .....:         break
   .....: else:
   .....:     print(u'finished')
5

This can be surprisingly useful, even if the name is a bit hard to remember.

While Loops

The while keyword is for when you don’t know how many loops you need. It continues to execute the body until condition is not True:

while a_condition:
   some_code
   in_the_body

While loops are more general than for loops. You can always express a for loop using the while structure, but the reverse is not always true. On the other hand, while is more error prone. You must remember to make progress in the body of the loop in order to allow the condition to become False. Otherwise you can fall victim to infinite loops.

i = 0;
while i < 5:
    print(i)

There are three approaches to terminating a while loop. You can use the break statement to end iteration:

In [150]: while True:
   .....:     i += 1
   .....:     if i > 10:
   .....:         break
   .....:     print(i, end=' ')
   .....:
1 2 3 4 5 6 7 8 9 10

Another approach is to set a flag variable. The boolean value of this variable starts as True Operations inside the loop update it to False, terminating the loop:

In [156]: import random
In [157]: keep_going = True
In [158]: while keep_going:
   .....:     num = random.choice(range(5))
   .....:     print(num)
   .....:     if num == 3:
   .....:         keep_going = False
   .....:
3

Finally, you can use a straight conditional statement as the test. Here, you update the value of the test variable such that the condition will evaluate to False:

In [161]: while i < 10:
   .....:     i += random.choice(range(4))
   .....:     print(i)
   .....:
0 0 2 3 4 6 8 8 8 9 12

Similarities

Both for and while loops can use break and continue for internal flow control. Both for and while loops can have an optional else block. In both loops, the statements in the else block are only executed if the loop terminates normally (no break).

String Features

Fun with Strings

Unicode v. Bytes

Python has two string types: byte strings and unicode objects.

Unicode is a classification system intended to allow a representation of all possible characters in all possible languages. Each character has a code point that is a byte or bytes which represents that character. When printed, these code points are translated into appropriate glyphs by the operating system.

When working in Python, you should always handle text as unicode objects. Text can be defined as any string meant to be read by a human via some output device.

Handling of unicode and bytes in Python3 is significantly different from Python2. In order to create compatible code (that will run the same in both systems), you should use one of the following two strategies:

You can import unicode_literals from the __future__ library. This must be the first line of code in your Python module.

from __future__ import unicode_literals
'this is a unicode string with élan'

Another approach is to be explicit about what type of string you are writing, using object literals:

u'this is a unicode string with élan'

The former strategy is a bit easier, but is not always safe in older legacy code bases, as it is an all-or-nothing operation. It makes every single string in the file a unicode object. The latter strategy is safer in this respect, as you get to choose which is which.

You can read more about compatible string handling at the Python-Future website.

Byte strings are strings that are composed entirely of numbers. This can be a bit confusing because they often appear to be letters. The string b"a" appears to contain the letter a, but really it contains the number 97 (or 01100001). Your terminal, your text editor, your OS is responsible for translating those numbers into characters when showing you the content of the string. But it’s still the number underneath. Be cautious about your assumptions.

Again, you have two strategies to work with bytestrings safely in Python 2 and Python 3. You can import unicode_literals and then specifically mark certain strings as bytestrings. Or you can mark certain strings as bytestrings. In either case, you have to mark bytestrings:

from __future__ import unicode_literals
b'polishing my resum\xc3\xa9 this week'
b'polishing my resum\xc3\xa9 this week'

The conversion of bytes to unicode and vice-versa should always take place at the I/O boundary. That means on the point where data is passing out of Python to the filesystem or network. Or the point where data enters Python from the filesystem or network.

At the point of crossing outbound, we can use the encode method of unicode objects to convert them to bytes. The argument to this function controls which codec is used to make the conversion. UTF8 is the most common codec in web work.

In [1]: fancy = u"Resumé"
In [2]: fancy
Out[2]: 'Resumé'
In [3]: fancy.encode('utf8')
Out[3]: b'Resum\xc3\xa9'

When data is inbound to Python, we can use the decode method of a byte string to convert it to Unicode. Again, passing a codec name selects which should be used for the conversion:

In [4]: bytes = _
In [5]: bytes
Out[5]: b'Resum\xc3\xa9'
In [6]: bytes.decode('utf8')
Out[6]: 'Resumé'

If no codec is specified, Python defaults to using the default encoding for the Python instance. This is usually ascii and is almost never the thing you really want. Be specific.

In Python 2, conversion of bytes to unicode and back was one of the largest sources of problems in programs. Both the encode and decode methods were supported by both byte strings and unicode objects. This led to a lot of implicit conversion, which of course uses default encoding.

It’s very easy when working entirely in English to have these types of problems an not know about them. If the characters in a string fall entirely within the ascii set, then no errors will occur. But as soon as characters beyond ascii are used, all sorts of trouble pops up.

Watch for UnicodeDecodeError and UnicodeEncodeError and write tests that use non-ascii characters.

String Manipulation

You can break strings apart using the split (py3) method. You have to make sure that the string you are splitting and the string you are using to split it are of the same type (bytes or unicode). The result is a list of the pieces:

In [167]: csv = "comma, separated, values"
In [168]: csv.split(', ')
Out[168]: ['comma', 'separated', 'values']

In the other direction, calling the join (py3) method will connect a sequence of pieces using the string on which it is called:

In [169]: psv = '|'.join(csv.split(', '))
In [170]: psv
Out[170]: 'comma|separated|values'

There are methods that allow us to change the case of text:

In [171]: sample = u'A long string of words'
In [172]: sample.upper()
Out[172]: u'A LONG STRING OF WORDS'
In [173]: sample.lower()
Out[173]: u'a long string of words'
In [174]: sample.swapcase()
Out[174]: u'a LONG STRING OF WORDS'
In [175]: sample.title()
Out[175]: u'A Long String Of Words'

And there are methods that allow us to test the nature of the characters in the text:

In [181]: number = u"12345"
In [182]: number.isnumeric()
Out[182]: True
In [183]: number.isalnum()
Out[183]: True
In [184]: number.isalpha()
Out[184]: False
In [185]: fancy = u"Th!$ $tr!ng h@$ $ymb0l$"
In [186]: fancy.isalnum()
Out[186]: False

Every character in a string has a numeric value. To see this value, use the ord (py3) builtin. The chr (py3) builtin reverses the process:

In [109]: for i in 'Cris':
   .....:     print(ord(i), end=' ')
67 114 105 115
In [110]: for i in (67,114,105,115):
   .....:     print(chr(i), end=' ')
C r i s

Building Strings

The concatenation operator + works for building strings out of fragments. But it’s not an efficient way to work. Avoid it.

Instead, use string formatting:

'Hello {0}!'.format(name)

It’s faster, and easier to maintain over time.

When building a format string, the placeholder is a pair of curly braces. They can be empty, but it’s better to put an integer into one, indicating the index of the argument to format (py3) to use. You can also pass keyword arguments to format, if the placeholders contain names instead of integers:

"My name is {1} {0}".format('Ewing', 'Cris')
"The {name} are {status}!".format(
    name='Seahawks', status='awesome'
)

Especially in legacy code you will see another method of formatting, using the % operator.

"This is a %s %s" % ('format', 'template')

This is still a functioning alternative and there is no pressing need to update. But you should prefer the new style in writing new code. The only dividing line is that the % operator supports both bytes and unicode objects, where in Python 3, .format is only a method on unicode objects.

There is a good website available that will help you learn everything you want to know about the formatting mini-language you can use to control these format specifiers.

Dictionaries and Sets

Dictionaries in Python are a mapping of keys to values. In other languages, they are called:

  • associative array
  • map
  • hash table
  • hash
  • key-value pair

The correct name of the type in Python is dict (py3)

You can build a new dict in a number of ways.

You can use the object literal:

{'key1': 3, 'key2': 5}

You can call the dict type object with a sequence of two-tuples. The first in each will become the key, the second the value:

>>> dict([('key1', 3),('key2', 5)])
{'key1': 3, 'key2': 5}

You can also use keyword arguments to the dict type object. In this case, you are limited to keys which are legal python names:

>>> dict(key1=3, key2=5)
{'key1': 3, 'key2': 5}

Indexing

To look up a value in a dict, we use the subscription operator, just like with sequences:

>>> d = {'name': 'Brian', 'score': 42}
>>> d['score']
42
>>> d = {1: 'one', 0: 'zero'}
>>> d[0]
'zero'

If you provide a key that is not in the dictionary, a KeyError is caused:

>>> d['non-existing key']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'non-existing key'

In a certain sense, Python is built on dicts. Namespaces are implemented as dicts. For this reason, the performance of lookup is highly optimized. Lookup time for any object is constant, regardless of the size of the dict.

When storing a value in a dict, you use a key. This key can be any immutable object (more on that later). In actuality, any object that is hashable can be used. What does that mean, though?

Hashing

Hashing is the process of converting arbitrarily large data to a small proxy (usually an integer). You can use any number of different algorithms to do this, MD5, SHA, etc. The key (if you’ll forgive the pun) is that the algorithm must always return the same proxy for the same input. In a dict, keys are hashed to an integer proxy, which is used to find a location in an array behind the scenes. This is efficient because a good hashing algorithms means only a very few key/value pairs correlate to any proxy.

What would happen if the proxy changed after a value was stored in the dict? Hashability requires that the object being hashed be immutable.

Dicts are inherently unordered collections. When you print them out, or look at them in the interpreter, this is not apparent. You will be fooled into thinking that you can rely on the order of the pairs. This is not true.

In [352]: d = {'one':1, 'two':2, 'three':3}
In [353]: d
Out[353]: {'one': 1, 'three': 3, 'two': 2}
In [354]: d.keys()
Out[354]: ['three', 'two', 'one']

Iteration and Dicts

You can use a dict with a for loop. By default, the keys are what are iterated over.

In [15]: d = {'name': 'Brian', 'score': 42}

In [16]: for x in d:
   ....:     print(x)
   ....:
score
name

If you want to iterate over values, or perhaps over the key/value pairs in the dict there are methods to support that.

In [2]: d.keys()
Out[2]: dict_keys(['score', 'name'])
In [3]: d.values()
Out[3]: dict_values([42, 'Brian'])
In [4]: d.items()
Out[4]: dict_items([('score', 42), ('name', 'Brian')])

In Python 2, there were nine methods on dicts that supplied these behaviors. The keys, values and items methods returned lists. The iter... methods (iterkeys, etc.) returned iterators, which were much more efficient for large dicts. The view... methods (viewkeys, etc.) return dict views which behaved as iterators, but also updated themselves as the dictionary changed.

In Python 3, the three remainin methods operate like the last of those. To get semantically equivalent code in Python 3, use the following map:

Python 2 Python 3
d.keys() list(d.keys())
d.values() list(d.values())
d.items() list(d.items())
d.iterkeys() iter(d.keys())
d.itervalues() iter(d.values())
d.iteritems() iter(d.items())
d.viewkeys() d.keys()
d.viewvalues() d.values()
d.viewitems() d.items()

You should also refer to Python Futures for additional compatible idioms.

Performance

Dictionaries are optimized for inserting and retrieving values:

  • indexing is fast and constant time: O(1)
  • Membership (x in s) constant time: O(1)
  • visiting all is proportional to n: O(n)
  • inserting is constant time: O(1)
  • deleting is constant time: O(1)

more on what exactly that means soon.

Miscellaneous

You can find all the methods of the dict type in the Python standard library documentation. But here are a number of interesting methods you may find useful:

Membership (on keys):

In [5]: d
Out[5]: {'that': 7, 'this': 5}

In [6]: 'that' in d
Out[6]: True

In [7]: 'this' not in d
Out[7]: False

The get method (py3) allows you to get a value or returns a default if the key you seek is not in the dict. The default value returned is None, but you can control it. It has the advantage of never causing a KeyError:

In [9]: d.get('this')
Out[9]: 5
In [11]: d.get(u'something', u'a default')
Out[11]: u'a default'

To remove a key/value pair from a dict, we use the pop method (py3). It takes a key as the optional argument. The value corresponding to the key is return and the key/value pair are removed. If no argument is supplied, an arbitrary key/value pair is removed, and the value returned.

In [19]: d.pop('this')
Out[19]: 5
In [20]: d
Out[20]: {'that': 7}
In [23]: d.popitem()
Out[23]: ('that', 7)
In [24]: d
Out[24]: {}

One of the most useful methods on the dict type is setdefault (py3). You pass it a key and a default value. If the key is present in the dict, the stored value is returned. If the key is not present, then the default value is stored and returned.

In [26]: d = {}
In [27]: d.setdefault(u'something', u'a value')
Out[27]: u'a value'
In [28]: d
Out[28]: {u'something': u'a value'}
In [29]: d.setdefault(u'something', u'a different value')
Out[29]: u'a value'
In [30]: d
Out[30]: {u'something': u'a value'}

Sets

A set is an unordered collection of distinct values. You can think of a set as a dict which has only keys and no values. You can create a set using the set literal ({}) or the set type object:

In [4]: {1, 2, 3}
Out[4]: {1, 2, 3}
In [5]: set()
Out[5]: set()
In [6]: set([1, 2, 3])
Out[6]: {1, 2, 3}
In [7]: {1, 2, 3}
Out[7]: {1, 2, 3}
In [8]: s = set()
In [9]: s.update([1, 2, 3])
In [10]: s
Out[10]: {1, 2, 3}
In [11]: s.add(4)
In [12]: s
Out[12]: {1, 2, 3, 4}

Sets share a lot of properties with dicts. Members of a set must be hashable, like dictionary keys, and for the same reason. Sets are also unordered, and so you cannot index them:

>>> s[1]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'set' object does not support indexing

Set support similar operations to dicts as well.

In [1]: s = set([1])
In [2]: s.pop()
Out[2]: 1
In [3]: s.pop()
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-3-e76f41daca5e> in <module>()
----> 1 s.pop()
KeyError: 'pop from an empty set'

In [4]: s = set([1,2,3])
In [5]: s.remove(2)
In [6]: s.remove(2)
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-6-542ac1b736c7> in <module>()
----> 1 s.remove(2)
KeyError: 2

Beyond all this, sets also operate as traditional mathematical sets. You get all the operations you might remember from set theory class:

s.isdisjoint(other)

s.issubset(other)

s.union(other, ...)

s.intersection(other, ...)

s.difference(other, ...)

s.symmetric_difference( other, ...)

Finally, if you need to have an immutable object that functions like a set, Python provides the frozenset type. It works just like a set, except that once constructed it may not be altered:

>>> fs = frozenset((3,8,5))
>>> fs.add(9)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'add'

File Reading and Writing

It’s often useful in programming to be able to open files in order to read data from them, or write data to them. Python has, for a long time, had a built-in function open which handled this operation. However, this builtin was created in the days before unicode was widely used, and it does not handle text that contains unicode particularly well.

For this reason, we have moved away from using the built-in open and toward using the open function from the io module. The io.open function is available in both Python 2 (2.6 and 2.7) and Python 3 and so provides a cross-compatible approach to opening files.

import io
f = io.open('secrets.txt', encoding='utf-8')
secret_data = f.read()
f.close()

By default, files are opened in “read text” mode, which automatically decode the bytes contained in the file to unicode. You may provide an encoding keyword argument to control the “codec” that is used to perform this conversion. The most common codec you will use is “utf-8”. If you do not provide an encoding, then Python will default to the value of sys.getdefaultencoding(), which is nearly always “ascii”.

If you have needs other than reading unicode test, you may use the mode argument ('rb' below) to control aspects of your interaction with the file.

For example, the rb mode opens a file for reading bytes. When you read data from a file opened in rb mode, it will be a bytestring. If you need to convert it to unicode, you can call decode on it at that point.

f = io.open('secrets.bin', 'rb')
secret_data = f.read()
f.close()

There are a number of modes available for files. Unfortunately, Python’s own documentation of the meaning of these modes is not very clear. You can use the man page for the unix command fopen which supports the same modes, and has much better information. One thing you should be careful of. Merely opening a file in w mode will always truncate the file, rendering it empty.

When you open a file with io.open it defaults to using “Universal Newline” mode. This means that while some operating systems use different characters as line endings, Python will always translate them into the *nix-tyle "\n" when you read data from the file. The "\n" characters are translated back to OS-Native line endings when you write data out to a file. You should always use the "\n" character as a line ending when writing strings in Python.

You should be aware that although there is no difference between reading in bytes or text mode on Unix operating systems, the two are quite different in Windows. You’ll be tempted to accept the default and always open files in text mode. This will break binary files on Windows. Don’t do it. Get in the habit of thinking carefully about whether the content of the file you are reading is text or binary data.

Beyond the mode and encoding parameters we’ve discussed, there are a number of other parameters to the io.open function.

The required first argument is the path to the file you wish to open.

The errors parameter allows you to control how errors in decoding file bytes to Python unicode are handled. You may specify that you wish to ignore errors, replace broken characters with a specific identifier, or use strict mode to force errors to terminate reading.

The other parameters are more advanced and will not come up often in your work with files. You may read about them in the io module documentation (py3).

Once you have an open file, you can read from it. The read method accepts an optional argument of a number of bytes to read. If you provide no value, the entire file (starting at your current position) is read.

header_size = 4096
f = open('secrets.txt')
secret_header = f.read(header_size)
secret_rest = f.read()
f.close()

Files are iterators, which means you can iterate through the lines of text they contain like so:

for line in io.open('secrets.txt'):
    print line

In addition, the readline method will read a single line at a time. The readlines method will read a file and return a list containing the lines of the file.

If you wish to write text to a file you’ve opened in w mode, you may do so with the write method: Newlines are not automatically appended to text written this way. If you want lines in your file, you must write the newline characters yourself (or place them in your text).

outfile = io.open('output.txt', 'w')
for i in range(10):
    outfile.write("this is line: {0}\n".format(i))

When you’ve opened a file in a “read” mode, you have the following methods available:

f.read() f.readline() f.readlines()

In “write” mode, you have rough equivalents. The write method writes a string to an open file. The writelines method takes a sequence of strings and writes them to a file. Remember, newlines are not automatically added (despite the name).

f.write(str) f.writelines(seq)

In any mode, a file has a few methods you can use to navigate through the file.

The file.seek(offset) method will move the file pointer to the byte of the file given by ‘offset’. The file.tell() method will return the byte number of the current position of the file pointer.

f.seek(offset) f.tell()

Finally, whenever you open a file, you must also close it. On certain operating systems, if you fail to do so it can render the file unusable by any other process.

file.close()

In Python, we say that anything which implements both the read and write method is File-like. There are a number of types which are file-like:

  • loggers
  • sys.stdout
  • urllib.open()
  • pipes, subprocesses
  • StringIO

When you have a file-like object you can treat is as if it were a file. A common use-case for this involves using the io.StringIO class. This class constructs an in-memory buffer that operates just like a file:

In [417]: from io import StringIO
In [420]: f = StringIO()
In [421]: f.write(u"somestuff")
In [422]: f.seek(0)
In [423]: f.read()
Out[423]: 'somestuff'

When writing tests for file-handling code this can be very useful. It allows you to make “fake files” that operate just like the real thing.

Legacy code will often contain references to modules named StringIO or cStringIO. These modules should be considered superseded by the io.StringIO class.

Paths and Directories

In Python, paths are often handled with simple strings (or Unicode strings) You can make absolute paths:

b"/home/cris/stuff.txt"
u'/usr/local/bin/python3'

or relative paths:

u'./secret.txt'
b'src/test_ack.py'

Either relative or absolute paths, bytes or unicode objects will work as the path argument to io.open().

The os module from the Python standard library gives you a number of useful tools for interacting with paths. You can get the current working directory with os.getcwd. You can change directories with os.chdir. You can turn any relative path into an absolute path with os.path.abspath. You can obtain the relative path from your current location to any absolute path with os.path.relpath().

os.getcwd() -- os.getcwdu() (u for Unicode)
os.chdir(path)
os.path.abspath()
os.path.relpath()

It’s possible to list the contents of a directory with os.listdir. You can make new directories with os.mkdir. You can even walk an entire file system using os.walk.

There’s much, much more to learn. Check out the documentation (py3)

Finally, if you’d prefer to work with paths in an Object-oriented style, the Python 3 standard library has added a new module, pathlib. This module can also be pip installed in Python 2 for compatibility.

With the module, you can create paths as objects, and then work with methods on them. This allows you access to all the operations in os.path and more.

In [1]: import pathlib
In [2]: pth = pathlib.Path('.')
In [3]: pth.is_dir()
Out[3]: True
In [4]: pth.absolute()
Out[4]: PosixPath('/Users/cewing/projects/training/codefellows/existing_course_repos/python-dev-accelerator')
In [5]: for f in pth.iterdir():
   ...:     print(f)
   ...:
.git
.gitignore
bin
build
cfpython.sublime-project
...