If the maximum values aren't too large to store the complete set of possibilities in memory (and it won't take forever to generate them), random.sample and itertools.product can be used effectively here:
import itertools
import random
def make_random_cities(num_cities, max_x, max_y):
return random.sample(list(itertools.product(range(max_x+1), range(max_y+1))), num_cities)
If the product of the inputs gets too large though, you could easily exceed main memory; in that case, your approach of looping until you get sufficient unique results is probably the best approach.
You could do samples of each range independently and then combine them together, but that would add uniqueness constraints to each axis, which I'm guessing you don't want.
For this specific case (unique numbers following a predictable pattern), you could use a trick to make this memory friendly while still avoiding the issue of arbitrarily long loops. Instead of taking the product of two ranges, you'd generate a single range (or in Py2, xrange) that encodes both unique values from the product in a single value:
def make_random_cities(num_cities, max_x, max_y):
max_xy = (max_x+1) * (max_y+1)
xys = random.sample(range(max_xy), num_cities)
return [divmod(xy, max_y+1) for xy in xys]
This means you have no large intermediate data to store (because Py3 range/Py2 xrange are "virtual" sequences, with storage requirements unrelated to the range of values they represent, and random.sample produces samples without needing to read all the values of the underlying sequence).