When it comes to approaching long, wordy questions like this one, the best thing to do (in my opinion) is to break it into parts and approach each one independently.
Lets do exactly that with the three rules provided:

This Sudoku grid needs to be filled with the digits 1 to 4 such that the following rules are maintained:

1. Each row contains the digits 1 to 4, without any duplicate values.
2. Each column contains the digits 1 to 4, without any duplicate values.
3. Each sub-grid contains the digits 1 to 4, without any duplicate values.

A "hidden" 4th rule is that the row and column inputs cant be < 1 or > 4, since that would be outside of the board, so lets start with that:

``````
def perform_move(board_list, row, column, value):
if row < 1 or column < 1 or row > 4 or column > 4:
return print("This move is invalid!")
``````

Now that we know the input row and columns are valid, lets get the 0-based index of them, since the board_list is a list and therefore also has a 0-based index.

``````
def perform_move(board_list, row, column, value):
if row < 1 or column < 1 or row > 4 or column > 4:
return print("This move is invalid!")
# get 0-based indexes
row -= 1
column -= 1
``````

Also, the 'value' parameter in this function is an integer, however the values of the board are in string form, so lets convert value to a string for consistency, since we will be doing checks with it later.

``````
def perform_move(board_list, row, column, value):
if row < 1 or column < 1 or row > 4 or column > 4:
return print("This move is invalid!")
# get 0-based indexes
value = str(value)
row -= 1
column -= 1
``````

Now lets start with rule 1, in order for the move to be valid the value cannot already be in the row we are trying to insert into, this is a relatively easy check to do:

``````
def perform_move(board_list, row, column, value):
if row < 1 or column < 1 or row > 4 or column > 4:
return print("This move is invalid!")
# get 0-based indexes
value = str(value)
row -= 1
column -= 1
if value in board_list[row]: # rule 1
return print("This move is invalid!")
``````

Now onto rule 2, the same thing but the column, this is a bit trickier since we need to iterate/loop through each row to get a certain column.

``````
def perform_move(board_list, row, column, value):
if row < 1 or column < 1 or row > 4 or column > 4:
return print("This move is invalid!")
# get 0-based indexes
value = str(value)
row -= 1
column -= 1
if value in board_list[row]: # rule 1
return print("This move is invalid!")
for a_row in board_list:
if value == a_row[column]: # rule 2
return print("This move is invalid!")
``````

Now for the hardest bit, the sub-grid. Lets represent the sub-grid as 2 lists, sub_row for the rows and sub_column for the column. Each of these lists will contain the range of indexes of the rows and columns in the sub-grid we are checking. E.g. S1 will have a sub_row of [0, 2] (row 0 and row 1) and a sub_column of [0, 2]; and S2 will have a sub_row of [2, 4] and a sub_column of [0, 2].

``````
def perform_move(board_list, row, column, value):
if row < 1 or column < 1 or row > 4 or column > 4:
return print("This move is invalid!")
# get 0-based indexes
value = str(value)
row -= 1
column -= 1
if value in board_list[row]: # rule 1
return print("This move is invalid!")
for a_row in board_list:
if value == a_row[column]: # rule 2
return print("This move is invalid!")
# row sub-grid getting
if row < 2:
sub_row = [0, 2]
else:
sub_row = [2, 4]
# column sub-grid getting
if column < 2:
sub_column = [0, 2]
else:
sub_column = [2, 4]
``````

Now we need to check the rows and columns in the sub-grid to see if they contain the value.
First we loop through each row in the board_list the range specified in sub_row.

``````
for a_sub_row in board_list[sub_row:sub_row]:
``````

Then check if the value is within the range of columns specified in sub_column:

``````
if value in a_sub_row[sub_column:sub_column]:
return print("This move is invalid!")
``````

If the code reaches past this for loop, the move satisfies all the rules specified, and we can update the board accordingly.

``````
def perform_move(board_list, row, column, value):
if row < 1 or column < 1 or row > 4 or column > 4:
return print("This move is invalid!")
# get 0-based indexes
value = str(value)
row -= 1
column -= 1
if value in board_list[row]: # rule 1
return print("This move is invalid!")
for a_row in board_list:
if value == a_row[column]: # rule 2
return print("This move is invalid!")
# row sub-grid getting
if row < 2:
sub_row = [0, 2]
else:
sub_row = [2, 4]
# column sub-grid getting
if column < 2:
sub_column = [0, 2]
else:
sub_column = [2, 4]

for a_sub_row in board_list[sub_row:sub_row]: # rule 3
if value in a_sub_row[sub_column:sub_column]:
return print("This move is invalid!")
board_list[row] = board_list[row][:column] + value + board_list[row][column + 1:]
``````
Back to 101 Index