How to change render samples using python
Sometimes Blender just doesn't update things when the code is executed &quot;blindly&quot;, so you need to imitate like some parts of it were called by user. To do so you need to create and register Blender Invoke or Modal Operator and then call it with ('INVOKE_DEFAULT'). In the most cases it solves such problems. I've written and tested this Modal Operator, it works. Maybe there's even no need in the whole modal section and you could place context.scene.cycles.samples = self.samples and bpy.ops.render.render('INVOKE_DEFAULT') right inside invoke method and just return {'FINISHED'} from there, but I just wanted to ensure that the samples have been changed using the modal loop. More information about Blender Operators you may find in the API documentation And here is the code with lots of comments:
import bpy
from bpy.props import IntProperty

class SetSamples(bpy.types.Operator):
    '''Blender Modal Operator'''
    bl_idname = 'render.set_cycles_samples'
    bl_label = 'Set Cycles Samples'
    samples : IntProperty(
        name=&quot;samples&quot;,
        min=0,
        default=20
    )
    timer = None
    
    def execute(self, context):
        # in execute method we just create a timer and add a modal handler
        # which handles events in real-time. We set the timer to the class
        # variable to be able to remove it later on.
        self.timer = bpy.context.window_manager.event_timer_add(.1, window=context.window)
        bpy.context.window_manager.modal_handler_add(self)
        return {'RUNNING_MODAL'} # this starts the modal() method
    
    def modal(self, context, event):
        # modal handles events in real-time
        if event.type == 'ESC':
            # in case something goes wrong and we need to abort it manually
            bpy.context.window_manager.event_timer_remove(self.timer) # remove timer
            return {'CANCELLED'} 
        elif event.type == 'TIMER':
            print('Tick!') # just a test to count how many times timer ticks
            # on every Timer tick the Operator checks: 
            if context.scene.cycles.samples == self.samples:
                # if everything worked correctly and Cycles samples have changed:
                bpy.context.window_manager.event_timer_remove(self.timer) # remove timer
                bpy.ops.render.render('INVOKE_DEFAULT') # render 
                print('FINISHED') # just a test message after
                return {'FINISHED'}
            else:
                # if samples haven't changed:
                context.scene.cycles.samples = self.samples # command to change samples
                context.scene.update_tag() # not sure what it does but it was recommended
                                           # in the comments to the question
                print(f'Cycles samples after changing: {context.scene.cycles.samples}') # test message 
        return {'PASS_THROUGH'} # returns us to the start of the modal() method
        
    def invoke(self, context, event):
        # Operator starts to execute from here when called with ('INVOKE_DEFAULT'),
        # like if it was called by user, not by script      
        return self.execute(context) # this starts the execute() method
    
def register():
    # as we use the Blender Operator class, it needs to be registered:
    bpy.utils.register_class(SetSamples)

def unregister():
    bpy.utils.unregister_class(SetSamples)

if __name__ == &quot;__main__&quot;:
    register()
    # after register you can use your Operator just like any other Operator in Blender
    # the last parameter is the samples you need
    bpy.ops.render.set_cycles_samples('INVOKE_DEFAULT', samples = 20)