Tic-tac-toe in python with OOP
Indents
PEP 8: Use 4 spaces per indentation level.

Main game loop
The way your game loop is set up now is a bit unelegant. You already implemented all the methods to be able to handle both players, there's only one piece missing:
from itertools import cycle

players = cycle(['X', 'O'])

cycle will provide a generator that infinitely repeats the iterable you pass it. This makes the loop quite a bit cleaner:
while not tictactoe.game_over():
    tictactoe.print_board()

    player = next(players)
    move = tictactoe.get_player_move(player)
    tictactoe.select_space(move, player)

I'd also suggest renaming select_space to something like do_move. Makes it a bit clearer what's going on.

Redundant calls to Tictactoe.available_moves()
In game_over and get_player_move you call self.available_moves() once without using the return value in any way. You can simply delete those lines.

game_over
The pattern
if self.has_won(&quot;X&quot;) or self.has_won(&quot;O&quot;) or len(self.available_moves()) ==0:
    return True
else:
    return False

can be simplified to
return self.has_won(&quot;X&quot;) or self.has_won(&quot;O&quot;) or len(self.available_moves()) == 0

What you were doing is basically manually returning the resulting bool value of a condition. We can instead simply return the condition directly.

print_board
Python lets you easily avoid an unelegant pattern
print(&quot;|    &quot; + self.board[0][0] + &quot; &quot; + self.board[0][1] + &quot; &quot; + self.board[0][2] + &quot;    |&quot;)

by using the * operator for unpacking an iterable
print(&quot;|   &quot;, *self.board[0], &quot;   |&quot;)

Since we need to do this for all rows in self.board we should let Python handle it for us as well:
for row in self.board:
    print(&quot;|   &quot;, *row, &quot;   |&quot;)


has_won
For a cleaner implementation of this method I would suggest implementing helper functions that provide lists of the rows, columns and diagonals of the board. They might also be useful for future feature additions.
def rows(self):
    return self.board[:]  # creates a mutable copy of the rows

def columns(self):
    return [[row[index] for row in self.board] for index in range(3)]

def diagonals(self):
    first = [self.board[index][index] for index in range(3)]
    second = [self.board[index][len(self.board[index]) - index - 1] for index in range(3)]
    return [first, second]

These methods are also ready to be used for boards of different sizes. Simply replace the 3s by a variable.
has_won is now a lot simpler and more readable:
def has_won(self, player):
    for line in self.rows() + self.columns() + self.diagonals():
        symbols = set(line)
        if player in symbols and len(symbols) == 1:
            return True

    return False

We can take this a step further (as pointed out in the comments) and change has_won to get_winner and return the winning symbol. This will also affect some other parts of your code:
class Tictactoe:
def get_winner(self):
    for line in self.rows() + self.columns() + self.diagonals():
        symbols = set(line)
        if len(symbols) == 1:
            return symbols.pop()

    return None


def game_over(self):
    return self.get_winner() is not None or len(self.available_moves()) == 0

Printing our winner in game:
def game(...):
    
    # main gameplay here
    
    if (winner := self.get_winner()) is not None:
        print(f&quot;{winner} wins&quot;)
    else:
        print(&quot;tie&quot;)